ATOM

Button

Press and the label swaps with a short lift; six intents from primary to destructive. One primary action per section.

Preview

Variants: primarysecondarytertiarydestructive-primarydestructive-secondarydestructive-tertiary (default: primary)

Sizes: xssmlxl (default: m)

Incluye animación (GSAP / motion).

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

Marketing CTA with motion:

```tsx import { Button } from '@/components/atoms/Button';

<Button variant="primary" size="l" animated> Get started </Button> ```

Form submit with busy state:

```tsx <Button type="submit" loading={pending} disabled={pending}> Save changes </Button> ```

## Accesibilidad

- Keep a clear accessible name; icon-only usage belongs on `icon-button`, not `button` with empty children. - Prefer explaining why a control is disabled nearby — a dead button alone fails WCAG name/purpose.

### Correcto

- El componente React agrega `aria-busy={true}` automaticamente en estado loading - `disabled={true}` en `<button>` desactiva el foco y lo anuncia al screen reader - href renderiza `<a>` con aria-disabled si esta desactivado - focus-visible muestra outline con var(--ring) para navegacion por teclado - prefers-reduced-motion: reduce desactiva transiciones y scale feedback

### Evitar

- No usar variant='destructive-primary' sin confirmacion previa (dialog o doble click) - No ocultar texto del boton solo con iconos — usa aria-label si el boton es icon-only (usar IconButton en su lugar)

## Cuándo no usar

- Not for navigation to another page — use `link-button` (real `href`). - Not for pure icon chrome — use `icon-button` with `aria-label`.

## Criterio de uso

- Usa `primary` para la única acción principal de una sección; reserva `destructive-*` para acciones irreversibles y acompáñalas con una consecuencia clara. - Mantén la etiqueta visible mientras `loading` comunica trabajo en curso. No sustituyas el label por un spinner manual: el estado loading ya bloquea el reintento. - En toolbars densas prefiere `s` o `xs` y deja `animated` apagado; el movimiento de texto aporta más en CTAs de marketing que en acciones repetidas.

## Gotchas

- El CSS del componente usa clases BEM globales; importarlo como CSS Module rompe los selectores. - Si la acción cambia de ruta, usa un enlace real aunque visualmente parezca botón. - **Nota**: El font-size es el mismo en todos los tamanos (13px). La diferenciacion viene de height y padding horizontal. Esto garantiza alineacion perfecta con inputs del mismo tamano. - **Ojo**: La animacion de texto requiere @atom-uikit/animations (GSAP). Sin el modulo, el prop animated no tiene efecto visual. La animacion respeta prefers-reduced-motion automaticamente. - **Nota**: Este CSS es autocontenido — no necesita tokens ni imports. Usalo para previews, prototipos o proyectos que no instalan @atom-uikit/css. - **Ojo**: GSAP SplitText es un plugin de pago. Para produccion usa @atom-uikit/animations que lo incluye como dependencia. Los CDN de arriba son para previews y prototipos unicamente.

Uso

import { Button } from '@/components/atoms/Button';

<Button variant="primary" size="m" animated>
  Save
</Button>

Props

PropTipoDefaultRango / opcionesWhatHow
variantselectprimary`primary`, `secondary`, `tertiary`, `destructive-primary`, `destructive-secondary`, `destructive-tertiary`Visual intent of the button surface and text.One primary per section. Use destructive-* only for irreversible actions. Default: primary.
sizeselectm`xs`, `s`, `m`, `l`, `xl`Hit target and type scale of the control.m for forms and content; s/xs for dense toolbars; l/xl for hero CTAs. Default: m.
animatedbooleanfalse`true` / `false`Whether the label text-swap animation runs on hover/press.false in dense tables or reduced-motion; true for marketing CTAs when motion is wanted. Default matches source: false.
loadingbooleanfalse`true` / `false`Shows a busy state and blocks re-clicks while work is in flight.true only during async submit; pair with disabled if the form must not resubmit.
disabledbooleanfalse`true` / `false`Prevents interaction and dims the control.true when prerequisites are unmet; prefer explaining why nearby, not only a dead button.

Gotchas

  • react

    Text-swap animation needs the real label as children string/node; do not replace children with a custom spinner while loading — use the loading prop.

  • a11y

    Keep a clear accessible name; icon-only usage belongs on icon-button, not button with empty children.

  • css-modules

    Button CSS is global BEM (.btn*). Import the published stylesheet globally; CSS Modules will scope classes and break styles.

Anatomía CSS

<div class="button">
  <span class="button__icon"></span>
  <span class="button__label"></span>
  <span class="button__spinner"></span>
  <span class="button__spinner-icon"></span>
</div>
ClasePropósito
buttonroot
button--destructive-primarymodifier
button--destructive-secondarymodifier
button--destructive-tertiarymodifier
button--disabledmodifier
button--lmodifier
button--loadingmodifier
button--mmodifier
button--primarymodifier
button--smodifier
button--secondarymodifier
button--tertiarymodifier
button--xlmodifier
button--xsmodifier
button__iconelement
button__labelelement
button__spinnerelement
button__spinner-iconelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
primarybg#0a0a0abutton.bg.primary
primaryfg#fafafabutton.fg.primary
primaryborder#00000000button.border.primary
primaryhover-bg#262626button.hover.bg.primary
primarypressed-bg#f5f5f5button.pressed.bg.primary
secondarybg#fafafabutton.bg.secondary
secondaryfg#0a0a0abutton.fg.secondary
secondaryborder#e5e5e5button.border.secondary
secondaryhover-bg#f5f5f5button.hover.bg.secondary
secondarypressed-bg#f5f5f5button.pressed.bg.secondary
tertiarybg#00000000button.bg.tertiary
tertiaryfg#0a0a0abutton.fg.tertiary
tertiaryborder#00000000button.border.tertiary
tertiaryhover-bg#f5f5f5button.hover.bg.tertiary
tertiarypressed-bg#f5f5f5button.pressed.bg.tertiary
destructive-primarybg#f84131button.bg.destructive.primary
destructive-primaryfg#0a0a0abutton.fg.destructive.primary
destructive-primaryborder#00000000button.border.destructive.primary
destructive-primaryhover-bg#c21e12button.hover.bg.destructive.primary
destructive-primarypressed-bg#c21e12button.pressed.bg.destructive.primary
destructive-secondarybg#fafafabutton.bg.destructive.secondary
destructive-secondaryfg#f84131button.fg.destructive.secondary
destructive-secondaryborder#f84131button.border.destructive.secondary
destructive-secondaryhover-bg#f5f5f5button.hover.bg.destructive.secondary
destructive-secondarypressed-bg#f5f5f5button.pressed.bg.destructive.secondary
destructive-tertiarybg#00000000button.bg.destructive.tertiary
destructive-tertiaryfg#f84131button.fg.destructive.tertiary
destructive-tertiaryborder#00000000button.border.destructive.tertiary
destructive-tertiaryhover-bg#f5f5f5button.hover.bg.destructive.tertiary
destructive-tertiarypressed-bg#f5f5f5button.pressed.bg.destructive.tertiary
alldisabled.fg.all#525252button.disabled.fg.all
sizeheightpaddingXfontSize
xs28px8px12.8px
s32px12px12.8px
m40px16px12.8px
l48px24px12.8px
xl52px32px12.8px

Animaciones

PropiedadDuraciónEasing
background-colorvar(--duration-300)var(--easing-out)
colorvar(--duration-300)var(--easing-out)
border-colorvar(--duration-300)var(--easing-out)
scalevar(--duration-500)cubic-bezier(0.32
0.72
0
1)
@keyframes button-spin

CSS mínimo funcional

Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).

/* -------------------------------------------------------------------------
   Button

   Sizing: explicit height + padding per size (shadcn/Radix convention).
   Gap, radius, icon-size remain em-relative for proportional scaling.

   Variants: primary, secondary, tertiary,
             destructive, destructive-secondary, destructive-tertiary
   Sizes:    xs, s, m (default), l, xl
   States:   enabled, hover, focus, active, disabled, loading
   ------------------------------------------------------------------------- */

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  border-radius: 0.625em;
  border: 1px solid transparent;
  font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
  font-weight: 500;
  line-height: 1;
  width: auto;
  max-width: 100%;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  text-decoration: none;
  box-shadow: 0 0 #0000;
  transition:
    background-color 300ms cubic-bezier(0.22, 1, 0.36, 1),
    color 300ms cubic-bezier(0.22, 1, 0.36, 1),
    border-color 300ms cubic-bezier(0.22, 1, 0.36, 1),
    scale 500ms cubic-bezier(0.32, 0.72, 0, 1);
  position: relative;
}

.button:active:not(.button--disabled):not(.button--loading) {
  scale: 0.955 0.925;
}

.button:focus-visible {
  outline: 2px solid #0a0a0a;
  outline-offset: 1.5px;
}

@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .button[data-button-animate]:hover:not(.button--disabled):not(.button--loading) {
    scale: 1.06 1.095;
  }
}

@media (prefers-reduced-motion: reduce) {
  .button {
    transition-duration: 0ms;
  }
  .button:active:not(.button--disabled) {
    scale: 1;
  }
}

/* ---- Sizes (same font-size, different height + padding) ---- */

.button--xs {
  height: 28px;
  padding: 0 8px;
  font-size: 12.8px;
}

.button--s {
  height: 32px;
  padding: 0 12px;
  font-size: 12.8px;
}

.button--m {
  height: 40px;
  padding: 0 16px;
  font-size: 12.8px;
}

.button--l {
  height: 48px;
  padding: 0 24px;
  font-size: 12.8px;
}

.button--xl {
  height: 52px;
  padding: 0 32px;
  font-size: 12.8px;
}

/* ---- Variants ---- */

.button--primary {
  background-color: #0a0a0a;
  color: #fafafa;
}
.button--primary:hover:not(.button--disabled) {
  background-color: #262626;
}
.button--primary:active:not(.button--disabled) {
  background-color: #f5f5f5;
}

.button--secondary {
  background-color: #fafafa;
  color: #0a0a0a;
  border-color: #e5e5e5;
}
.button--secondary:hover:not(.button--disabled) {
  background-color: #f5f5f5;
}
.button--secondary:active:not(.button--disabled) {
  background-color: #f5f5f5;
}

.button--tertiary {
  background-color: #00000000;
  color: #0a0a0a;
}
.button--tertiary:hover:not(.button--disabled) {
  background-color: #f5f5f5;
}
.button--tertiary:active:not(.button--disabled) {
  background-color: #f5f5f5;
}

.button--destructive-primary {
  background-color: #f84131;
  color: #0a0a0a;
}
.button--destructive-primary:hover:not(.button--disabled) {
  background-color: #c21e12;
}
.button--destructive-primary:active:not(.button--disabled) {
  background-color: #c21e12;
}

.button--destructive-secondary {
  background-color: #fafafa;
  color: #f84131;
  border-color: #f84131;
}
.button--destructive-secondary:hover:not(.button--disabled) {
  background-color: #f5f5f5;
}
.button--destructive-secondary:active:not(.button--disabled) {
  background-color: #f5f5f5;
}

.button--destructive-tertiary {
  background-color: #00000000;
  color: #f84131;
}
.button--destructive-tertiary:hover:not(.button--disabled) {
  background-color: #f5f5f5;
}
.button--destructive-tertiary:active:not(.button--disabled) {
  background-color: #f5f5f5;
}

/* ---- States ---- */

.button--disabled {
  color: #525252;
  cursor: not-allowed;
  pointer-events: none;
}

.button--disabled.button--primary,
.button--disabled.button--secondary,
.button--disabled.button--destructive-primary,
.button--disabled.button--destructive-secondary {
  background-color: #f5f5f5;
  border-color: transparent;
}

.button--disabled.button--tertiary,
.button--disabled.button--destructive-tertiary {
  background-color: transparent;
  border-color: transparent;
}

.button--loading {
  cursor: wait;
  pointer-events: none;
}

.button--loading .button__label {
  opacity: 0;
}

.button--loading .button__spinner {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ---- Parts ---- */

.button__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
}

.button__icon svg {
  width: 100%;
  height: 100%;
}

.button__label {
  display: inline-flex;
  align-items: center;
}


.button__spinner {
  display: none;
}

.button--loading .button__spinner {
  display: flex;
}

.button__spinner-icon {
  animation: button-spin 700ms linear infinite;
}

@keyframes button-spin {
  to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .button__spinner-icon {
    animation: none;
  }
}

Codigo fuente

components-react
components/atoms/Button.tsx
import { forwardRef, type ButtonHTMLAttributes, type AnchorHTMLAttributes, type ReactNode } from 'react';

type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'tertiary'
  | 'destructive-primary'
  | 'destructive-secondary'
  | 'destructive-tertiary';

type ButtonSize = 'xs' | 's' | 'm' | 'l' | 'xl';

type ButtonBaseProps = {
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  loading?: boolean;
  animated?: boolean;
  iconLeft?: ReactNode;
  iconRight?: ReactNode;
  children: ReactNode;
  className?: string;
};

type ButtonAsButton = ButtonBaseProps &
  Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps> & {
    href?: never;
  };

type ButtonAsAnchor = ButtonBaseProps &
  Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & {
    href: string;
  };

export type ButtonProps = ButtonAsButton | ButtonAsAnchor;

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

const Spinner = () => (
  <svg className="button__spinner-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
    <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
    <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  </svg>
);

export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'm',
      disabled = false,
      loading = false,
      animated = false,
      iconLeft,
      iconRight,
      children,
      className,
      ...props
    },
    ref,
  ) => {
    const classes = cn(
      'button',
      `button--${variant}`,
      `button--${size}`,
      disabled && 'button--disabled',
      loading && 'button--loading',
      className,
    );

    const animateAttrs = animated
      ? { 'data-button-animate': '' }
      : {};

    const label = animated ? (
      <span className="button__label">
        <span className="button__label-inner">
          <span className="button__text is--default" data-button-text="">{children}</span>
          <span className="button__text is--hover" data-button-text="" aria-hidden="true">{children}</span>
        </span>
      </span>
    ) : (
      <span className="button__label">{children}</span>
    );

    const content = (
      <>
        {iconLeft && <span className="button__icon">{iconLeft}</span>}
        {label}
        {iconRight && <span className="button__icon">{iconRight}</span>}
        {loading && (
          <span className="button__spinner">
            <Spinner />
          </span>
        )}
      </>
    );

    if ('href' in props && props.href) {
      const isInactive = disabled || loading;
      return (
        <a
          ref={ref as React.Ref<HTMLAnchorElement>}
          className={classes}
          aria-disabled={isInactive || undefined}
          tabIndex={isInactive ? -1 : undefined}
          onClick={isInactive ? (e: React.MouseEvent) => e.preventDefault() : undefined}
          {...animateAttrs}
          {...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
        >
          {content}
        </a>
      );
    }

    return (
      <button
        ref={ref as React.Ref<HTMLButtonElement>}
        className={classes}
        disabled={disabled || loading}
        aria-busy={loading}
        {...animateAttrs}
        {...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
      >
        {content}
      </button>
    );
  },
);

Button.displayName = 'Button';

Webflow

Webflowbutton

Pega en el Designer como application/json, luego convierte a Component (Atom / Button) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.

Setup del sitio (una vez)

Custom Code → Head:

<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css">
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/webflow.css">

Custom Code → Footer (before </body>):

<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
<script src="https://atom-web-ds.vercel.app/v1/animations.js"></script>
<script>window.__atomMotionCleanup = AtomMotion.initAll();</script>

DOM contract — no quites estos atributos

  • data-button-animate
  • data-link-button-animate
  • data-toggle-group-animate
  • data-button-text

Anatomía: [data-button-text]

Unsupported (no silencioso)

  • :focus-visible on .button:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .button:active:not(.button--disabled):not(.button--loading)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--primary:hover:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--primary:active:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--secondary:hover:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--secondary:active:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--tertiary:hover:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .button--tertiary:active:not(.button--disabled)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)

Tras pegar: Create component → nombre Atom / Button → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categoriaactions
Lectura...
Visitas...
Ayuda?Slack

Componente

Button

Source

components-react / cssDisponible via MCP: atom_uikit_source("button")
Abrir en Storybook