Theme
On this page (16)
Component Skill — components/button/component-skill.md

Native basis

<button> element. Also works on <a> for link-style buttons.

Web Platform APIs

<button>:focus-visiblecommandfor / commandprefers-reduced-motionprefers-contrastforced-colors

Classes

.btn

Variants (data-variant)

defaultPrimary fill, white textsecondaryMuted fill, dark textoutlineBorder + shadow, transparent fillghostTransparent, accent on hoverdestructiveRed fill for danger actionslinkInline text link, underline on hover

Sizes (data-size)

xsHeight: 1.75remsmHeight: 2remmdHeight: 2.25remlgHeight: 2.75remxlHeight: 3.25remiconHeight: 2.25remicon-xsHeight: 1.75remicon-smHeight: 2remicon-lgHeight: 2.75remicon-xlHeight: 3.25rem

Notes

• The link variant resets height and padding - it flows inline.

• SVGs inside buttons auto-size to 1rem unless they have a size-* class.

• The button works on <a> tags for styled navigation links.

§Variants

Six styles via data-variant. Default has a solid primary fill.

§Sizes

The full five-step scale via data-size - the default equals md - plus icon-only variants.

§Icon

Square button for icon-only actions. Requires aria-label.

§With Icon

Icons auto-size to 1rem. Place before or after the label.

§Loading

aria-busy + a .spinner child marks a pending action. An enabled busy button spins; a disabled one freezes the spinner (the dimming carries the pending signal) - button.css pauses .spinner under :disabled.

§Disabled

50% opacity, no pointer events.

Use <a> instead of <button> for navigation.

§Rounded

Add style='border-radius:9999px' for pill-shaped buttons.

§Soft and dashed

Two more styles: soft is a tint of the color with the text in it; dashed reads as 'add something here'.

§Colors

data-tone paints any style in success, warning, info or destructive - solid fills with the color, the other styles write in it (mixed toward the text color, so amber stays readable).

§Custom colors

data-tone='custom' takes your own color from --btn-color (and the text on a solid fill from --btn-color-fg) - every style follows it.

§Emojis

An emoji is text - it sits in the label like an icon. For an emoji-only button, hide the glyph and name the button with aria-label.

§Frames

The frame-* classes from shapes.css replace the button's border - double, offset, corners, sketch, gradient, stitched.

§Aura

Wrap a button in an .aura (shapes.css) for an animated light around it - set --shape-round to the button's radius and display:inline-grid so it hugs the button.

§Left-to-right and right-to-left

Buttons are logical: an icon before the label leads in either direction. Mark directional icons (arrows, chevrons) data-rtl-flip and they mirror under dir='rtl'; symmetric icons stay put.

§CSS view file

/* -- Button component ------------------------------------------ */
@layer components {
  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    white-space: nowrap;
    font-size: 0.875rem;
    font-weight: 500;
    font-family: var(--font-sans);
    line-height: 1;
    border-radius: var(--radius-md);
    border: 1px solid transparent;
    cursor: pointer;
    text-decoration: none;
    transition: all 150ms ease;
    outline: none;
    flex-shrink: 0;
    height: 2.25rem;
    padding: 0 1rem;
    background-color: var(--primary);
    color: var(--primary-foreground);
    & svg {
      pointer-events: none;
      flex-shrink: 0;
      &:not([class*="size-"]) { width: 1rem; height: 1rem; }
    }
    /* a loading .spinner inside a button takes the button's own text color —
       spinner.css defaults it to --muted-foreground, invisible on a primary */
    & .spinner { color: inherit; }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    &:disabled, &[aria-disabled="true"] {
      pointer-events: none;
      opacity: 0.5;
    }
    /* Loading state (aria-busy + a .spinner child): a disabled button must
       freeze its spinner - "pending" is signaled by the dimming, a frozen
       wheel is the accepted pattern; only an ENABLED busy button spins. */
    &:disabled .spinner,
    &[aria-disabled="true"] .spinner {
      animation-play-state: paused;
    }
    /* -- Variants -------------------------------------------- */
    &[data-variant="default"] {
      background-color: var(--primary);
      color: var(--primary-foreground);
      &:hover { opacity: 0.9; }
    }
    &[data-variant="secondary"] {
      background-color: var(--secondary);
      color: var(--secondary-foreground);
      /* --secondary is near-white in light themes, so an opacity fade would
         wash the button further into the page background (invisible hover).
         Mixing in the foreground color instead darkens light themes and
         lightens dark ones - always moving away from the page surface. */
      &:hover {
        background-color: color-mix(in oklch, var(--secondary) 85%, var(--secondary-foreground));
      }
    }
    &[data-variant="outline"] {
      background-color: var(--background);
      color: var(--foreground);
      border: 1px solid var(--border);
      box-shadow: var(--shadow-xs);
      &:hover {
        background-color: var(--accent);
        color: var(--accent-foreground);
      }
    }
    &[data-variant="ghost"] {
      background-color: transparent;
      color: var(--foreground);
      border-color: transparent;
      &:hover {
        background-color: var(--accent);
        color: var(--accent-foreground);
      }
    }
    &[data-variant="destructive"] {
      background-color: var(--destructive);
      color: var(--destructive-foreground);
      &:hover { opacity: 0.9; }
    }
    &[data-variant="link"] {
      background-color: transparent;
      color: var(--primary);
      height: auto;
      padding: 0;
      text-underline-offset: 4px;
      &:hover { text-decoration: underline; }
    }
    /* soft: a tint of the color, text in the color */
    &[data-variant="soft"] {
      background-color: color-mix(in oklch, var(--primary) 12%, transparent);
      color: color-mix(in oklch, var(--primary) 80%, var(--foreground));
      &:hover { background-color: color-mix(in oklch, var(--primary) 20%, transparent); }
    }
    /* dashed: a drop-zone / "add" look */
    &[data-variant="dashed"] {
      background-color: transparent;
      color: var(--foreground);
      border: 1px dashed color-mix(in oklch, var(--foreground) 35%, transparent);
      &:hover {
        background-color: var(--accent);
        color: var(--accent-foreground);
        border-style: solid;
      }
    }
    /* -- Tones: any variant in another color -------------------
       data-tone="success" / "warning" / "info" / "destructive" - or
       data-tone="custom" with style="--btn-color: …; --btn-color-fg: …"
       (the text on a solid fill, default white). Solid buttons fill with the
       tone; outline / ghost / soft / dashed / link write in it, mixed toward
       the foreground so a light tone (amber) stays readable. */
    &[data-tone] {
      --_tone: var(--btn-color, var(--primary));
      --_tone-fg: var(--btn-color-fg, #fff);
      --_tone-ink: color-mix(in oklch, var(--_tone) 78%, var(--foreground));
    }
    &[data-tone="success"] { --_tone: oklch(0.6 0.15 150); }
    &[data-tone="warning"] { --_tone: oklch(0.8 0.16 80); --_tone-fg: oklch(0.28 0.06 60); }
    &[data-tone="info"] { --_tone: oklch(0.6 0.16 250); }
    &[data-tone="destructive"] { --_tone: var(--destructive); --_tone-fg: var(--destructive-foreground, #fff); }
    &[data-tone]:is(:not([data-variant]), [data-variant="default"]) {
      background-color: var(--_tone);
      color: var(--_tone-fg);
      &:hover { opacity: 0.9; }
    }
    &[data-tone][data-variant="outline"] {
      background-color: transparent;
      color: var(--_tone-ink);
      border-color: var(--_tone);
      &:hover { background-color: color-mix(in oklch, var(--_tone) 12%, transparent); color: var(--_tone-ink); }
    }
    &[data-tone]:is([data-variant="ghost"], [data-variant="dashed"]) {
      color: var(--_tone-ink);
      &:hover { background-color: color-mix(in oklch, var(--_tone) 14%, transparent); color: var(--_tone-ink); }
    }
    &[data-tone][data-variant="dashed"] { border-color: var(--_tone); }
    &[data-tone][data-variant="soft"] {
      background-color: color-mix(in oklch, var(--_tone) 16%, transparent);
      color: var(--_tone-ink);
      &:hover { background-color: color-mix(in oklch, var(--_tone) 26%, transparent); }
    }
    &[data-tone][data-variant="link"] { color: var(--_tone-ink); }
    /* -- RTL: arrows and other directional icons mirror ----------
       mark them data-rtl-flip; symmetric icons stay as they are */
    &:dir(rtl) [data-rtl-flip] { scale: -1 1; }
    /* -- Sizes ----------------------------------------------- */
    &[data-size="xs"]      { height: 1.75rem; padding: 0 0.5rem;  font-size: 0.75rem; border-radius: var(--radius-sm); }
    &[data-size="sm"]      { height: 2rem;    padding: 0 0.75rem; font-size: 0.8125rem; }
    &[data-size="md"]      { height: 2.25rem; padding: 0 1rem;    font-size: 0.875rem; }
    &[data-size="lg"]      { height: 2.75rem; padding: 0 2rem;    font-size: 1rem; }
    &[data-size="xl"]      { height: 3.25rem; padding: 0 2.5rem;  font-size: 1.125rem; }
    &[data-size="icon"]    { height: 2.25rem; width: 2.25rem;  padding: 0; }
    &[data-size="icon-xs"] { height: 1.75rem; width: 1.75rem;  padding: 0; border-radius: var(--radius-sm); }
    &[data-size="icon-sm"] { height: 2rem;    width: 2rem;     padding: 0; }
    &[data-size="icon-lg"] { height: 2.75rem; width: 2.75rem;  padding: 0; }
    &[data-size="icon-xl"] { height: 3.25rem; width: 3.25rem;  padding: 0; }
  }
  /* -- Accessibility ------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    .btn { transition: none; }
  }
  @media (prefers-contrast: more) {
    .btn {
      border: 2px solid transparent;
      &[data-variant="default"],
      &[data-variant="secondary"],
      &[data-variant="destructive"] {
        border-color: currentColor;
      }
      &[data-variant="outline"],
      &[data-variant="ghost"] {
        border-color: var(--foreground);
      }
    }
  }
  @media (forced-colors: active) {
    .btn {
      border: 1px solid ButtonText;
      &:disabled, &[aria-disabled="true"] {
        border-color: GrayText;
        color: GrayText;
      }
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub