Button
Press and the label swaps with a short lift; six intents from primary to destructive. One primary action per section.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| variant | select | primary | `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. |
| size | select | m | `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. |
| animated | boolean | false | `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. |
| loading | boolean | false | `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. |
| disabled | boolean | false | `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>
| Clase | Propósito |
|---|---|
button | root |
button--destructive-primary | modifier |
button--destructive-secondary | modifier |
button--destructive-tertiary | modifier |
button--disabled | modifier |
button--l | modifier |
button--loading | modifier |
button--m | modifier |
button--primary | modifier |
button--s | modifier |
button--secondary | modifier |
button--tertiary | modifier |
button--xl | modifier |
button--xs | modifier |
button__icon | element |
button__label | element |
button__spinner | element |
button__spinner-icon | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| primary | bg | #0a0a0a | button.bg.primary |
| primary | fg | #fafafa | button.fg.primary |
| primary | border | #00000000 | button.border.primary |
| primary | hover-bg | #262626 | button.hover.bg.primary |
| primary | pressed-bg | #f5f5f5 | button.pressed.bg.primary |
| secondary | bg | #fafafa | button.bg.secondary |
| secondary | fg | #0a0a0a | button.fg.secondary |
| secondary | border | #e5e5e5 | button.border.secondary |
| secondary | hover-bg | #f5f5f5 | button.hover.bg.secondary |
| secondary | pressed-bg | #f5f5f5 | button.pressed.bg.secondary |
| tertiary | bg | #00000000 | button.bg.tertiary |
| tertiary | fg | #0a0a0a | button.fg.tertiary |
| tertiary | border | #00000000 | button.border.tertiary |
| tertiary | hover-bg | #f5f5f5 | button.hover.bg.tertiary |
| tertiary | pressed-bg | #f5f5f5 | button.pressed.bg.tertiary |
| destructive-primary | bg | #f84131 | button.bg.destructive.primary |
| destructive-primary | fg | #0a0a0a | button.fg.destructive.primary |
| destructive-primary | border | #00000000 | button.border.destructive.primary |
| destructive-primary | hover-bg | #c21e12 | button.hover.bg.destructive.primary |
| destructive-primary | pressed-bg | #c21e12 | button.pressed.bg.destructive.primary |
| destructive-secondary | bg | #fafafa | button.bg.destructive.secondary |
| destructive-secondary | fg | #f84131 | button.fg.destructive.secondary |
| destructive-secondary | border | #f84131 | button.border.destructive.secondary |
| destructive-secondary | hover-bg | #f5f5f5 | button.hover.bg.destructive.secondary |
| destructive-secondary | pressed-bg | #f5f5f5 | button.pressed.bg.destructive.secondary |
| destructive-tertiary | bg | #00000000 | button.bg.destructive.tertiary |
| destructive-tertiary | fg | #f84131 | button.fg.destructive.tertiary |
| destructive-tertiary | border | #00000000 | button.border.destructive.tertiary |
| destructive-tertiary | hover-bg | #f5f5f5 | button.hover.bg.destructive.tertiary |
| destructive-tertiary | pressed-bg | #f5f5f5 | button.pressed.bg.destructive.tertiary |
| all | disabled.fg.all | #525252 | button.disabled.fg.all |
| size | height | paddingX | fontSize |
|---|---|---|---|
xs | 28px | 8px | 12.8px |
s | 32px | 12px | 12.8px |
m | 40px | 16px | 12.8px |
l | 48px | 24px | 12.8px |
xl | 52px | 32px | 12.8px |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-300) | var(--easing-out) |
color | var(--duration-300) | var(--easing-out) |
border-color | var(--duration-300) | var(--easing-out) |
scale | var(--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
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
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-animatedata-link-button-animatedata-toggle-group-animatedata-button-text
Anatomía: [data-button-text]
Unsupported (no silencioso)
:focus-visibleon.button:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.button:active:not(.button--disabled):not(.button--loading)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.button--primary:hover:not(.button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.button--primary:active:not(.button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.button--secondary:hover:not(.button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.button--secondary:active:not(.button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.button--tertiary:hover:not(.button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.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.