ATOM

IconButton

A square control that holds only an icon and rotates slightly on press. Toolbars, close, and dense actions.

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

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

PropTipoDefaultRango / opcionesWhatHow
variantselectprimary`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.
sizeselectm`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.
disabledbooleanfalse`true` / `false`Prevents interaction and dims the control.true when the action is unavailable; prefer a tooltip explaining why. Default: false.
loadingbooleanfalse`true` / `false`Shows busy state and blocks re-clicks.true only during async work; pair with disabled semantics for forms. Default: false.
animatedbooleanfalse`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>
ClasePropósito
icon-buttonroot
icon-button--destructive-primarymodifier
icon-button--destructive-secondarymodifier
icon-button--destructive-tertiarymodifier
icon-button--disabledmodifier
icon-button--lmodifier
icon-button--loadingmodifier
icon-button--mmodifier
icon-button--primarymodifier
icon-button--smodifier
icon-button--secondarymodifier
icon-button--tertiarymodifier
icon-button--xlmodifier
icon-button--xsmodifier
icon-button__iconelement
icon-button__spinnerelement

Tokens resueltos

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

VariantePropValorToken
allborder1px solid transparent(literal)
primarybg#0a0a0abutton.bg.primary
primaryfg#fafafabutton.fg.primary
primarydisabled-bg#f5f5f5muted
secondarybg#fafafabutton.bg.secondary
secondaryfg#0a0a0abutton.fg.secondary
secondaryborder#e5e5e5button.border.secondary
secondarydisabled-bg#f5f5f5muted
tertiarybg#00000000button.bg.tertiary
tertiaryfg#0a0a0abutton.fg.tertiary
tertiarydisabled-bgtransparent(literal)
destructive-primarybg#f84131button.bg.destructive.primary
destructive-primaryfg#0a0a0abutton.fg.destructive.primary
destructive-primarydisabled-bg#f5f5f5muted
destructive-secondarybg#fafafabutton.bg.destructive.secondary
destructive-secondaryfg#f84131button.fg.destructive.secondary
destructive-secondaryborder#f84131button.border.destructive.secondary
destructive-secondarydisabled-bg#f5f5f5muted
destructive-tertiarybg#00000000button.bg.destructive.tertiary
destructive-tertiaryfg#f84131button.fg.destructive.tertiary
destructive-tertiarydisabled-bgtransparent(literal)
alldisabled-fg#525252button.disabled.fg.all
primarydisabled-bordertransparent(literal)
secondarydisabled-bordertransparent(literal)
destructive-primarydisabled-bordertransparent(literal)
destructive-secondarydisabled-bordertransparent(literal)
tertiarydisabled-bordertransparent(literal)
destructive-tertiarydisabled-bordertransparent(literal)

Animaciones

PropiedadDuraciónEasing
background-color0.3scubic-bezier(0.19
1
0.22
1)
color0.3scubic-bezier(0.19
1
0.22
1)
border-color0.3scubic-bezier(0.19
1
0.22
1)
scale0.45scubic-bezier(0.32
0.72
0
1)
rotate0.5scubic-bezier(0.19
1
0.22
1)
rotate0.6s0.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

components-react
components/atoms/IconButton.tsx
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

Webflowicon-button

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

Componentes relacionados

On this page

Detalles

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

Componente

IconButton

Source

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