IconButton
A square control that holds only an icon and rotates slightly on press. Toolbars, close, and dense actions.
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
Toolbar close control:
```tsx import { IconButton } from '@/components/atoms/IconButton';
<IconButton variant="tertiary" size="xs" aria-label="Close dialog" icon={<CloseIcon />} onClick={onClose} /> ```
## Accesibilidad
- `aria-label` is required (no text children). Name the action, not the glyph (“Close dialog”, not “X”). - `loading` sets `aria-busy` and disables the control — keep adjacent status text if the wait matters.
### Correcto
- aria-label es OBLIGATORIO — sin el, el boton es invisible para screen readers - `aria-busy={true}` se agrega automaticamente en loading - `disabled={true}` remueve el boton del tab order - focus-visible muestra outline con var(--ring) - prefers-reduced-motion desactiva rotacion y scale
### Evitar
- No usar IconButton sin aria-label — es el error mas comun - No usar como reemplazo de Button cuando hay espacio para texto — el texto siempre es mas accesible - No confiar en el tooltip como unica forma de identificar la accion — el tooltip no es accesible en touch
## Cuándo no usar
- Actions that need a visible text label → `Button`. - Navigation with a URL → `LinkButton` (or a real link), not an icon button that only looks like navigation.
## Criterio de uso
- Usa `aria-label` para nombrar la acción, no el dibujo: “Cerrar diálogo” comunica intención; “X” sólo describe el icono. - En toolbars, mantén un mismo tamaño y agrupa acciones relacionadas; reserva `primary` para una sola acción dominante. - Usa `loading` durante trabajo asíncrono y evita reemplazar el icono con contenido propio: el componente ya comunica el estado ocupado.
## Gotchas
- El control debe conservar un objetivo táctil suficiente aunque uses `xs` en una tabla densa. - El CSS se publica como BEM global; no lo encapsules con CSS Modules. - **Nota**: El icono siempre es 1.25em relativo al font-size (13px), lo que da ~16px. Pasa size=\{16} a los iconos de lucide para match perfecto. - **Nota**: CSS autocontenido — no necesita tokens ni imports. La animacion de rotacion es puro CSS (data-icon-button-animate), no requiere GSAP.
Uso
import { IconButton } from '@/components/atoms/IconButton';
<IconButton variant="ghost" size="m" aria-label="Close" />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| variant | select | primary | `primary`, `secondary`, `tertiary`, `destructive-primary`, `destructive-secondary`, `destructive-tertiary` | Visual intent of the square icon control. | Match the surrounding button family. One primary per section; destructive only for irreversible actions. Default: primary. |
| size | select | m | `xs`, `s`, `m`, `l`, `xl` | Hit target of the icon-only control. | m for toolbars; s/xs for dense tables; l/xl for hero chrome. Default: m. |
| disabled | boolean | false | `true` / `false` | Prevents interaction and dims the control. | true when the action is unavailable; prefer a tooltip explaining why. Default: false. |
| loading | boolean | false | `true` / `false` | Shows busy state and blocks re-clicks. | true only during async work; pair with disabled semantics for forms. Default: false. |
| animated | boolean | false | `true` / `false` | Whether the icon rotation / motion runs on interaction. | true for marketing chrome; false in data-dense tables or reduced-motion contexts. Default: false. |
Gotchas
- a11y
Always provide an accessible name (aria-label) — there is no visible text children.
- css-modules
Uses global BEM (.button*). Import published CSS globally; Modules will scope and break styles.
Anatomía CSS
<div class="icon-button"> <span class="icon-button__icon"></span> <span class="icon-button__spinner"></span> </div>
| Clase | Propósito |
|---|---|
icon-button | root |
icon-button--destructive-primary | modifier |
icon-button--destructive-secondary | modifier |
icon-button--destructive-tertiary | modifier |
icon-button--disabled | modifier |
icon-button--l | modifier |
icon-button--loading | modifier |
icon-button--m | modifier |
icon-button--primary | modifier |
icon-button--s | modifier |
icon-button--secondary | modifier |
icon-button--tertiary | modifier |
icon-button--xl | modifier |
icon-button--xs | modifier |
icon-button__icon | element |
icon-button__spinner | 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 |
|---|---|---|---|
| all | border | 1px solid transparent | (literal) |
| primary | bg | #0a0a0a | button.bg.primary |
| primary | fg | #fafafa | button.fg.primary |
| primary | disabled-bg | #f5f5f5 | muted |
| secondary | bg | #fafafa | button.bg.secondary |
| secondary | fg | #0a0a0a | button.fg.secondary |
| secondary | border | #e5e5e5 | button.border.secondary |
| secondary | disabled-bg | #f5f5f5 | muted |
| tertiary | bg | #00000000 | button.bg.tertiary |
| tertiary | fg | #0a0a0a | button.fg.tertiary |
| tertiary | disabled-bg | transparent | (literal) |
| destructive-primary | bg | #f84131 | button.bg.destructive.primary |
| destructive-primary | fg | #0a0a0a | button.fg.destructive.primary |
| destructive-primary | disabled-bg | #f5f5f5 | muted |
| 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 | disabled-bg | #f5f5f5 | muted |
| destructive-tertiary | bg | #00000000 | button.bg.destructive.tertiary |
| destructive-tertiary | fg | #f84131 | button.fg.destructive.tertiary |
| destructive-tertiary | disabled-bg | transparent | (literal) |
| all | disabled-fg | #525252 | button.disabled.fg.all |
| primary | disabled-border | transparent | (literal) |
| secondary | disabled-border | transparent | (literal) |
| destructive-primary | disabled-border | transparent | (literal) |
| destructive-secondary | disabled-border | transparent | (literal) |
| tertiary | disabled-border | transparent | (literal) |
| destructive-tertiary | disabled-border | transparent | (literal) |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
color | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
border-color | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
scale | 0.45s | cubic-bezier(0.32 |
0.72 | | |
0 | | |
1) | | |
rotate | 0.5s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
rotate | 0.6s | 0.05s cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
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).
/* -------------------------------------------------------------------------
Icon Button
Square button with icon only, no label.
Shares color variants with Button. Own sizing and animation.
Variants: primary, secondary, tertiary,
destructive, destructive-secondary, destructive-tertiary
Sizes: xs, sm, default (default), lg, xl
Animation: icon 360deg rotation on hover (opt-in via data-icon-button-animate)
------------------------------------------------------------------------- */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
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;
line-height: 1;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
text-decoration: none;
transition:
background-color 0.3s cubic-bezier(0.19, 1, 0.22, 1),
color 0.3s cubic-bezier(0.19, 1, 0.22, 1),
border-color 0.3s cubic-bezier(0.19, 1, 0.22, 1),
scale 0.45s cubic-bezier(0.32, 0.72, 0, 1);
position: relative;
}
.icon-button:active:not(.icon-button--disabled):not(.icon-button--loading) {
scale: 0.925;
}
.icon-button:focus-visible {
outline: 2px solid #0a0a0a;
outline-offset: 1.5px;
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
.icon-button[data-icon-button-animate]:hover:not(.icon-button--disabled):not(.icon-button--loading) {
scale: 1.095;
}
}
@media (prefers-reduced-motion: reduce) {
.icon-button {
transition-duration: 0ms;
}
.icon-button:active:not(.icon-button--disabled) {
scale: 1;
}
}
/* ---- Sizes (square: width = height) ---- */
.icon-button--xs { width: 28px; height: 28px; font-size: 12.8px; }
.icon-button--s { width: 32px; height: 32px; font-size: 12.8px; }
.icon-button--m { width: 40px; height: 40px; font-size: 12.8px; }
.icon-button--l { width: 48px; height: 48px; font-size: 12.8px; }
.icon-button--xl { width: 52px; height: 52px; font-size: 12.8px; }
/* ---- Variants (same tokens as Button) ---- */
.icon-button--primary {
background-color: #0a0a0a;
color: #fafafa;
}
.icon-button--primary:hover:not(.icon-button--disabled) {
background-color: #262626;
}
.icon-button--primary:active:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--secondary {
background-color: #fafafa;
color: #0a0a0a;
border-color: #e5e5e5;
}
.icon-button--secondary:hover:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--secondary:active:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--tertiary {
background-color: #00000000;
color: #0a0a0a;
}
.icon-button--tertiary:hover:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--tertiary:active:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--destructive-primary {
background-color: #f84131;
color: #0a0a0a;
}
.icon-button--destructive-primary:hover:not(.icon-button--disabled) {
background-color: #c21e12;
}
.icon-button--destructive-primary:active:not(.icon-button--disabled) {
background-color: #c21e12;
}
.icon-button--destructive-secondary {
background-color: #fafafa;
color: #f84131;
border-color: #f84131;
}
.icon-button--destructive-secondary:hover:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--destructive-secondary:active:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--destructive-tertiary {
background-color: #00000000;
color: #f84131;
}
.icon-button--destructive-tertiary:hover:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
.icon-button--destructive-tertiary:active:not(.icon-button--disabled) {
background-color: #f5f5f5;
}
/* ---- States ---- */
.icon-button--disabled {
color: #525252;
cursor: not-allowed;
pointer-events: none;
}
.icon-button--disabled.icon-button--primary,
.icon-button--disabled.icon-button--secondary,
.icon-button--disabled.icon-button--destructive-primary,
.icon-button--disabled.icon-button--destructive-secondary {
background-color: #f5f5f5;
border-color: transparent;
}
.icon-button--disabled.icon-button--tertiary,
.icon-button--disabled.icon-button--destructive-tertiary {
background-color: transparent;
border-color: transparent;
}
.icon-button--loading {
cursor: wait;
pointer-events: none;
}
/* ---- Icon ---- */
.icon-button__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.25em;
height: 1.25em;
will-change: transform;
transition: rotate 0.5s cubic-bezier(0.19, 1, 0.22, 1);
}
.icon-button__icon svg {
width: 100%;
height: 100%;
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
.icon-button[data-icon-button-animate]:is(:hover, :focus-visible) .icon-button__icon,
[data-hover]:is(:hover, :focus-visible) .icon-button[data-icon-button-animate] .icon-button__icon {
rotate: 360deg;
transition: rotate 0.6s 0.05s cubic-bezier(0.19, 1, 0.22, 1);
}
}
/* ---- Spinner ---- */
.icon-button__spinner {
display: none;
}
.icon-button--loading .icon-button__icon {
opacity: 0;
}
.icon-button--loading .icon-button__spinner {
display: flex;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
}
Codigo fuente
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; type IconButtonVariant = | 'primary' | 'secondary' | 'tertiary' | 'destructive-primary' | 'destructive-secondary' | 'destructive-tertiary'; type IconButtonSize = 'xs' | 's' | 'm' | 'l' | 'xl'; export type IconButtonProps = { variant?: IconButtonVariant; size?: IconButtonSize; disabled?: boolean; loading?: boolean; animated?: boolean; icon: ReactNode; 'aria-label': string; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>; 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 IconButton = forwardRef<HTMLButtonElement, IconButtonProps>( ( { variant = 'primary', size = 'm', disabled = false, loading = false, animated = false, icon, className, ...props }, ref, ) => { const classes = cn( 'icon-button', `icon-button--${variant}`, `icon-button--${size}`, disabled && 'icon-button--disabled', loading && 'icon-button--loading', className, ); return ( <button ref={ref} className={classes} disabled={disabled || loading} aria-busy={loading} {...(animated ? { 'data-icon-button-animate': '' } : {})} {...props} > <span className="icon-button__icon">{icon}</span> {loading && ( <span className="icon-button__spinner"> <Spinner /> </span> )} </button> ); }, ); IconButton.displayName = 'IconButton';
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / IconButton) 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">
Unsupported (no silencioso)
:focus-visibleon.icon-button:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.icon-button:active:not(.icon-button--disabled):not(.icon-button--loading)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--primary:hover:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--primary:active:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--secondary:hover:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--secondary:active:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--tertiary:hover:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.icon-button--tertiary:active:not(.icon-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / IconButton → Publish.