Navigation

v0.0.4
Actions

Button

A raised control that lifts on hover and sinks when pressed.

On this page

A button is a small raised sheet with a half-pixel contour. It lifts by half a pixel under the pointer and sinks into a well when pressed, so its state reads through light and depth rather than color. Use .btn on a <button> for actions and on an <a> for navigation that should look like one.

Button

<button type="button" class="btn">Cancel</button><button type="button" class="btn btn-primary">Save changes</button>

#Variants

The default button is moonstone. Choose the variant by the weight of the action:

  • btn-primary is ink on moonstone and pearl on obsidian. Use one per view, for the action the view exists for.
  • btn-quiet has no material until touched; use it in toolbars, headers and dense rows.
  • btn-danger keeps the raised material and gives its label the danger ink.
  • btn-brand is the one hued control, in the brand indigo: a moment such as a homepage call to action or an upgrade. Use at most one per view. Its face, edge and glow are mixed from --color-brand, so it follows your brand color.

Variants

<button type="button" class="btn">Default</button><button type="button" class="btn btn-primary">Primary</button><button type="button" class="btn btn-quiet">Quiet</button><button type="button" class="btn btn-danger">Delete</button><button type="button" class="btn btn-brand">Get started</button>

#Destructive actions

btn-danger alone is the quiet form: a raised button whose label is in the danger ink, for a delete among other actions. Together with btn-primary it becomes the confirming button of a destructive act, filled with the danger color the way a brand button is filled with the brand. Keep the filled form for the last step, such as the button that confirms inside a dialog, and let its label name the act.

Destructive actions

<button type="button" class="btn btn-danger">Delete</button><button type="button" class="btn btn-primary btn-danger">Delete project</button><button type="button" class="btn btn-primary btn-danger" disabled>Delete project</button>

#Sizes

The default height is 38px, with 32px for dense UI and 44px for prominent actions. Padding, label size, icon size and radius change together. On touch screens every size grows to at least 44px.

Sizes

<button type="button" class="btn btn-sm">Small</button><button type="button" class="btn">Medium</button><button type="button" class="btn btn-lg">Large</button>

#With icons

Put icon on the icon itself. The button sets its size, 16px by default, 14px in small and 18px in large buttons, at a 1.5 stroke. btn-square makes a square button: give it an accessible name with aria-label.

With icons

<button type="button" class="btn btn-primary">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <path d="M12 5v14M5 12h14" />  </svg>New project</button><button type="button" class="btn btn-square" aria-label="Search">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <circle cx="11" cy="11" r="6" />    <path d="m20 20-4.35-4.35" />  </svg></button><button type="button" class="btn btn-quiet btn-square" aria-label="More">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <circle cx="5" cy="12" r="1" />    <circle cx="12" cy="12" r="1" />    <circle cx="19" cy="12" r="1" />  </svg></button>

#Pill

btn-pill rounds the ends fully and adds a little inline space, since a round end feels tighter than a flat one.

Pill

<button type="button" class="btn btn-pill">Follow</button><button type="button" class="btn btn-primary btn-pill">Subscribe</button>

#Toggle

A quiet button with aria-pressed="true" stays sunk into a well. Update the attribute with your own script. A quiet button also stays sunk, without script, while the menu or popover placed right after it is open.

Toggle

<button type="button" class="btn btn-quiet btn-square" aria-label="Bold" aria-pressed="true">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />  </svg></button><button type="button" class="btn btn-quiet btn-square" aria-label="Italic" aria-pressed="false">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <path d="M11 5h6M7 19h6M14 5l-4 14" />  </svg></button>

#Group

btn-group joins related actions into one outer shape. Each button keeps its own material; the group squares the corners that meet and lets neighboring contours overlap into a single seam. A button in a group does not lift on its own, so the shape stays whole. Give the group role="group" and a label.

Group

<div class="btn-group" role="group" aria-label="History">  <button type="button" class="btn">Undo</button>  <button type="button" class="btn">Redo</button></div><div class="btn-group" role="group" aria-label="Deploy">  <button type="button" class="btn btn-primary">Deploy</button>  <button type="button" class="btn btn-primary btn-square" aria-label="Deploy options">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <path d="m6 9 6 6 6-6" />    </svg>  </button></div>

The second button of a split action usually opens a menu: add popovertarget and the menu places itself against it.

#Pagination

A row of quiet square buttons makes page links. The current page has aria-current="page" and stays sunk into a well, like a pressed toggle.

Pagination

<nav class="flex items-center gap(2)" aria-label="Pagination">  <a class="btn btn-quiet btn-square btn-sm" href="#page-1" aria-label="Previous page">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <path d="m15 6-6 6 6 6" />    </svg>  </a>  <a class="btn btn-quiet btn-square btn-sm" href="#page-1">1</a>  <a class="btn btn-quiet btn-square btn-sm" href="#page-2" aria-current="page">2</a>  <a class="btn btn-quiet btn-square btn-sm" href="#page-3">3</a>  <a class="btn btn-quiet btn-square btn-sm" href="#page-3" aria-label="Next page">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <path d="m9 6 6 6-6 6" />    </svg>  </a></nav>

#Busy

Set aria-busy="true" while an action runs. The button keeps its label and material, stops lifting and shows an inline spinner sized like its icons. Change the label to say what is happening, and ignore further activation in your handler.

Busy

<button type="button" class="btn" aria-busy="true">  <span class="spinner spinner-inline" aria-hidden="true"></span>Saving…</button><button type="button" class="btn btn-primary" aria-busy="true">  <span class="spinner spinner-inline" aria-hidden="true"></span>Publishing…</button>

#Disabled

A disabled button drops back into the surface: a flat well with disabled ink. Links cannot be disabled; use aria-disabled="true" and remove the href.

Disabled

<button type="button" class="btn" disabled>Default</button><button type="button" class="btn btn-primary" disabled>Primary</button>

#Adjust one instance

Utilities win over component styles because the utilities layer comes after components:

Full width

<button type="button" class="btn btn-primary btn-lg w-full">Continue</button>

#Classes

ClassDescription
btnThe raised base button
btn-primaryInk on moonstone, pearl on obsidian: the main action of a view. With btn-danger it fills with the danger hue to confirm a destructive act. With btn
btn-quietNo material until touched, for toolbars, headers and dense rows. With btn
btn-brandThe one hued control, filled from --color-brand; at most one per view. With btn
btn-dangerThe danger ink on the label of a destructive action. With btn
btn-sm32px tall; the default is 38px. With btn
btn-lg44px tall; the default is 38px. With btn
btn-squareA square button for one icon; it needs an accessible name. With btn
btn-pillFully rounded ends. With btn
btn-groupA container that joins its buttons into one shape

#Custom properties

PropertyDefaultDescription
--btn-height2.375remMinimum block size
--btn-padding-x1remInline padding
--btn-gap.5remSpace between icon and label
--btn-font-size.84375remLabel size
--btn-radiusvar(--radius-control)Corner radius: 10px, with 8px for small and 12px for large buttons
--icon-size1remSize of an icon inside the button

#Source

@master/ui/components/button.css
/* * Button: a small raised sheet with one state order. * pressed (sinks into the well) < rest (raised) < hover (raised-hover, lifted by half a pixel). * State reads through light and depth, never through hue. Its edge is a half-pixel contour. */@layer components {  .btn {    --btn-height: 2.375rem;    --btn-padding-x: 1rem;    --btn-gap: .5rem;    --btn-font-size: .84375rem;    --btn-radius: var(--radius-control);    --btn-lift: var(--lift);    --icon-size: 1rem;    touch-action: manipulation;    /* A 24px-grid icon has about 3px of air inside its box; pull 2px of it into the padding and gap. */    --icon-offset: -.125rem;    position: relative;    display: inline-flex;    flex-shrink: 0;    align-items: center;    justify-content: center;    gap: var(--btn-gap);    min-height: var(--btn-height);    max-width: 100%;    margin: 0;    padding: 0 var(--btn-padding-x);    border: 0;    border-radius: calc(var(--btn-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    @apply --surface-sheet;    color: var(--color-text-strong);    box-shadow: var(--shadow-raised);    font: var(--font-weight-control) var(--btn-font-size)/1.2 var(--font-family-sans);    font-feature-settings: inherit;    letter-spacing: 0;    text-align: center;    text-decoration: none;    overflow-wrap: anywhere;    appearance: none;    user-select: none;    -webkit-user-select: none;    -webkit-tap-highlight-color: transparent;    cursor: pointer;    /*     * Backgrounds swap instantly. A gradient cannot interpolate, so transitioning background from a     * gradient face to a pressed color would fade from a transparent background-color and flash.     */    transition-property: box-shadow, translate, color;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    @media (hover: hover) {      &:hover:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {        translate: 0 calc(-1 * var(--btn-lift));        box-shadow: var(--shadow-raised-hover);      }    }    &:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {      translate: 0 var(--btn-lift);      background: var(--color-surface-inset);      box-shadow: var(--shadow-pressed);      transition-duration: var(--duration-press);    }    &:focus-visible {      @apply --focus-ring;    }    &:is(:disabled, [aria-disabled="true"]) {      background: var(--color-surface-inset);      color: var(--color-text-disabled);      box-shadow: var(--shadow-flat);      cursor: not-allowed;    }    /* Busy keeps the label and shows a spinner beside it; the button stops responding. */    &[aria-busy="true"] {      cursor: progress;    }    &.btn-sm {      --btn-height: 2rem;      --btn-padding-x: .75rem;      --btn-gap: .375rem;      --btn-font-size: .78125rem;      --btn-radius: var(--radius-control-sm);      --icon-size: .875rem;      --icon-offset: -.0625rem;    }    &.btn-lg {      --btn-height: 2.75rem;      --btn-padding-x: 1.25rem;      --btn-gap: .5rem;      --btn-font-size: .9375rem;      --btn-radius: var(--radius-control-lg);      --icon-size: 1.125rem;    }    /* A square button for one icon; give it an accessible name. */    &.btn-square {      width: var(--btn-height);      padding: 0;    }    /* A round end reads tighter than a flat one, so the pill takes 2px more inline space. */    &.btn-pill {      --btn-radius: 999px;      /* A pill's end is a true half circle. */      corner-shape: round;      padding-inline: calc(var(--btn-padding-x) + 2px);      &.btn-square {        padding: 0;      }    }    /*     * Primary: ink on moonstone, pearl on obsidian. Its body is its own edge: a hairline one step darker than     * the face, a line of light along the top and a soft turn at the bottom, with no drawn outline.     */    &.btn-primary {      @apply --on-ink;      color: var(--color-text-inverse);      @apply --control-ink;      @media (hover: hover) {        &:hover:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {          @apply --control-ink-hover;        }      }      &:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {        @apply --control-ink-pressed;      }      &:is(:disabled, [aria-disabled="true"]) {        background: var(--color-surface-inset);        color: var(--color-text-disabled);        box-shadow: var(--shadow-flat);      }    }    /* Quiet: no material until touched, for toolbars, headers and dense rows. */    &.btn-quiet {      background: transparent;      color: var(--color-text-muted);      box-shadow: none;      @media (hover: hover) {        &:hover:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {          translate: none;          @apply --surface-sheet;          color: var(--color-text-strong);          box-shadow: var(--shadow-raised);        }      }      /*       * A toggle that is on, the current page in a row of page links, or a button whose menu or popover       * is open right after it stays sunk into a well.       */      &:is([aria-pressed="true"], [aria-current="page"], :has(+ .menu:popover-open, + .popover:popover-open)) {        background: var(--color-surface-inset);        color: var(--color-text-strong);        box-shadow: var(--shadow-pressed);      }      &:is(:disabled, [aria-disabled="true"]) {        background: transparent;        box-shadow: none;      }    }    /* Danger: the same raised material, with the status ink on its label. */    &.btn-danger:not(:disabled, [aria-disabled="true"]) {      color: var(--color-danger);    }    /*     * A toned face: one hue for the whole button. The face, its edge, the light on it and the glow under it     * are all mixed from --tone by the theme's --control-tone, so the two variants below share one recipe.     * Brand is the one hued control, at most one per view, and follows whatever --color-brand is.     * A primary that is also danger is the confirming button of a destructive act: the hue says what the     * word already says, and the rest of the interface stays in ink.     */    &.btn-brand {      --tone: var(--color-brand);      --tone-ink: var(--color-brand-ink);    }    &.btn-primary.btn-danger {      --tone: var(--color-danger);      --tone-ink: var(--color-text-inverse);    }    &:is(.btn-brand, .btn-primary.btn-danger) {      @apply --on-ink;      @apply --control-tone;      @media (hover: hover) {        &:hover:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {          @apply --control-tone-hover;        }      }      &:active:not(:disabled, [aria-disabled="true"], [aria-busy="true"]) {        @apply --control-tone-pressed;      }      &:is(:disabled, [aria-disabled="true"]) {        background: var(--color-surface-inset);        color: var(--color-text-disabled);        box-shadow: var(--shadow-flat);      }    }    @media (pointer: coarse) {      min-height: max(var(--btn-height), 2.75rem);      &.btn-square {        min-width: max(var(--btn-height), 2.75rem);      }    }    @media (prefers-reduced-motion: reduce) {      --btn-lift: 0px;    }    /* Shadows disappear in forced colors; an outline keeps the edge without moving layout. */    @media (forced-colors: active) {      outline: 1px solid ButtonText;      outline-offset: -1px;      &:focus-visible {        outline: 2px solid Highlight;        outline-offset: 2px;      }      &:is(:disabled, [aria-disabled="true"]) {        outline-color: GrayText;        color: GrayText;      }    }  }  /*   * Button group: related actions that share one outer shape. Each button keeps its own material; the group   * squares the corners that meet and lets neighboring contours overlap into one seam. A button in a group   * does not lift on its own, and the one in use paints over its neighbors.   */  .btn-group {    display: inline-flex;    max-width: 100%;    isolation: isolate;    vertical-align: middle;    .btn {      --btn-lift: 0px;      flex-shrink: 1;      &:not(:first-child) {        margin-inline-start: .5px;        border-start-start-radius: 0;        border-end-start-radius: 0;      }      &:not(:last-child) {        border-start-end-radius: 0;        border-end-end-radius: 0;      }      &:hover {        z-index: 1;      }      &:focus-visible {        z-index: 2;      }    }  }}