Actions

Button

Starts an action, toggles one, or links with the same look. Six weights, four sizes and a loading state.

Built on
<button><a>
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.8 kB
Dependencies
React
Try its options

Loading its options.

Code
import { Button } from "@/components/equip/button";

export default function Demo() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "center" }}>
      <Button variant="accent">Save changes</Button>
      <Button>Publish</Button>
      <Button variant="outline">Cancel</Button>
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
  <button type="button" class="eq-button" data-variant="accent" data-size="m">
    <span class="eq-button-label">Save changes</span>
  </button>
  <button type="button" class="eq-button" data-variant="solid" data-size="m">
    <span class="eq-button-label">Publish</span>
  </button>
  <button type="button" class="eq-button" data-variant="outline" data-size="m">
    <span class="eq-button-label">Cancel</span>
  </button>
</div>

Examples

Variants

One accent per screen. Solid for the default action, outline and ghost for everything quieter.

Code
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      <Button variant="accent">Accent</Button>
      <Button variant="solid">Solid</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="danger">Delete project</Button>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px">
  <button type="button" class="eq-button" data-variant="accent" data-size="m">
    <span class="eq-button-label">Accent</span>
  </button>
  <button type="button" class="eq-button" data-variant="solid" data-size="m">
    <span class="eq-button-label">Solid</span>
  </button>
  <button type="button" class="eq-button" data-variant="outline" data-size="m">
    <span class="eq-button-label">Outline</span>
  </button>
  <button type="button" class="eq-button" data-variant="ghost" data-size="m">
    <span class="eq-button-label">Ghost</span>
  </button>
  <button type="button" class="eq-button" data-variant="danger" data-size="m">
    <span class="eq-button-label">Delete project</span>
  </button>
</div>

Sizes

Code
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <Button size="s" variant="outline">Small</Button>
      <Button size="m" variant="outline">Medium</Button>
      <Button size="l" variant="outline">Large</Button>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
  <button type="button" class="eq-button" data-variant="outline" data-size="s">
    <span class="eq-button-label">Small</span>
  </button>
  <button type="button" class="eq-button" data-variant="outline" data-size="m">
    <span class="eq-button-label">Medium</span>
  </button>
  <button type="button" class="eq-button" data-variant="outline" data-size="l">
    <span class="eq-button-label">Large</span>
  </button>
</div>

Loading and disabled

Loading sets aria-busy and aria-disabled instead of disabled, so the button keeps keyboard focus but does nothing until the work is done.

Code
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      <Button loading>Saving</Button>
      <Button variant="outline" loading>Saving</Button>
      <Button disabled>Unavailable</Button>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px">
  <button type="button" aria-disabled="true" class="eq-button" data-variant="solid" data-size="m" aria-busy="true">
    <span class="eq-button-spinner" aria-hidden="true"></span>
    <span class="eq-button-label">Saving</span>
  </button>
  <button type="button" aria-disabled="true" class="eq-button" data-variant="outline" data-size="m" aria-busy="true">
    <span class="eq-button-spinner" aria-hidden="true"></span>
    <span class="eq-button-label">Saving</span>
  </button>
  <button type="button" class="eq-button" data-variant="solid" data-size="m" disabled>
    <span class="eq-button-label">Unavailable</span>
  </button>
</div>

Pressed

pressed sets aria-pressed, so the label stays put and assistive tech hears the button as on or off.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";

export default function Example() {
  const [muted, setMuted] = useState(true);
  const [pinned, setPinned] = useState(false);
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      <Button variant="outline" pressed={muted} onClick={() => setMuted((v) => !v)}>
        Mute alerts
      </Button>
      <Button variant="ghost" pressed={pinned} onClick={() => setPinned((v) => !v)}>
        Pin to sidebar
      </Button>
    </div>
  );
}

As a link

With href it renders a real a element that looks the same; disabled drops the href and sets aria-disabled, so it cannot be followed.

Code
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <Button href="#pricing" variant="accent">See pricing</Button>
      <Button href="#docs" variant="outline">Read the docs</Button>
      <Button href="#changelog" variant="ghost" size="s">Changelog</Button>
      <Button href="#beta" variant="outline" disabled>Join the beta</Button>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
  <a href="#pricing" class="eq-button" data-variant="accent" data-size="m">
    <span class="eq-button-label">See pricing</span>
  </a>
  <a href="#docs" class="eq-button" data-variant="outline" data-size="m">
    <span class="eq-button-label">Read the docs</span>
  </a>
  <a href="#changelog" class="eq-button" data-variant="ghost" data-size="s">
    <span class="eq-button-label">Changelog</span>
  </a>
  <a role="link" aria-disabled="true" class="eq-button" data-variant="outline" data-size="m">
    <span class="eq-button-label">Join the beta</span>
  </a>
</div>

Icon only

square makes an icon-only button as wide as it is tall at every size; the aria-label is its name.

Code
import { Button } from "@/components/equip/button";

const gear = (
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <circle cx="8" cy="8" r="2.25" />
    <path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
  </svg>
);

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <Button square size="s" variant="ghost" aria-label="Settings" lead={gear} />
      <Button square variant="outline" aria-label="Settings" lead={gear} />
      <Button square size="l" aria-label="Settings" lead={gear} />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
  <button aria-label="Settings" type="button" class="eq-button" data-variant="ghost" data-size="s" data-square="true">
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="8" cy="8" r="2.25" />
      <path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
    </svg>
  </button>
  <button aria-label="Settings" type="button" class="eq-button" data-variant="outline" data-size="m" data-square="true">
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="8" cy="8" r="2.25" />
      <path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
    </svg>
  </button>
  <button aria-label="Settings" type="button" class="eq-button" data-variant="solid" data-size="l" data-square="true">
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <circle cx="8" cy="8" r="2.25" />
      <path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
    </svg>
  </button>
</div>

Dense rows

Extra small is the 24px minimum target; quiet keeps the action in secondary ink so the row's own text leads.

Code
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
      <Button size="xs" variant="outline">Retry</Button>
      <Button size="xs" variant="quiet">Mark as read</Button>
      <Button
        size="xs"
        variant="quiet"
        square
        aria-label="Remove invoice-march.pdf"
        lead={
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
            <path d="M6 6l12 12M18 6 6 18" />
          </svg>
        }
      />
      <Button size="s" variant="quiet">Dismiss all</Button>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">
  <button type="button" class="eq-button" data-variant="outline" data-size="xs">
    <span class="eq-button-label">Retry</span>
  </button>
  <button type="button" class="eq-button" data-variant="quiet" data-size="xs">
    <span class="eq-button-label">Mark as read</span>
  </button>
  <button aria-label="Remove invoice-march.pdf" type="button" class="eq-button" data-variant="quiet" data-size="xs" data-square="true">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
      <path d="M6 6l12 12M18 6 6 18" />
    </svg>
  </button>
  <button type="button" class="eq-button" data-variant="quiet" data-size="s">
    <span class="eq-button-label">Dismiss all</span>
  </button>
</div>

Props

PropTypeDefaultWhat it does
variant"solid" | "accent" | "outline" | "ghost" | "quiet" | "danger""solid"Visual weight. Use accent for the one primary action on a screen. Quiet is a ghost in secondary ink, for row actions that should not compete with the text.
size"xs" | "s" | "m" | "l""m"Control height: 24, 28, 34 or 42px. Use xs only inside a dense row, such as a file list or a reaction bar.
loadingbooleanfalseShows a spinner and sets aria-busy and aria-disabled. It keeps focus but does not act: no click, no submit, no navigation.
hrefstringnoneRenders a real link with the same look. disabled then drops the href and sets aria-disabled.
leadReactNodenoneContent before the label, usually an icon.
trailReactNodenoneContent after the label, such as a shortcut.
squarebooleanfalseFor an icon-only button: as wide as it is tall at every size. Give it an aria-label.
pressedbooleannoneMakes it a toggle button. Sets aria-pressed and, when true, the solid pressed look of a toggle group item.
...restComponentProps<"button"> | ComponentProps<"a">noneEvery native button attribute, including type, disabled and form; with href, every native link attribute, such as target and rel.

Anatomy

root.eq-buttonThe native button, or an a when href is given.
label.eq-button-label
spinner.eq-button-spinnerReplaces lead while loading.

Keyboard

EnterSpaceActivates the button.
EnterFollows the link, when href is given.

Notes

  • Defaults to type="button", so it never submits a form by accident. Pass type="submit" when you mean it.
  • Loading keeps the button focusable: it sets aria-disabled and aria-busy rather than disabled, drops onClick and turns a submit button into type="button" until it is done.
  • A form with a single text field still submits on Enter while its only submit button is loading, so guard the submit handler too.
  • aria-disabled="true" passed by you is honoured the same way: the disabled look, still focusable, onClick dropped.
  • Pass href to navigate rather than act: it renders a real a, so middle click, open in new tab and the link role all work.
  • A disabled link has no href and aria-disabled="true", so it leaves the tab order like a disabled button; a loading link stays focusable.
  • Renders on the server with no client JavaScript. Pass onClick from a client component as you would to a native button.
  • For a toggle, keep the label the same and pass pressed rather than swapping the variant; assistive tech then hears on or off.
  • The pressed look is the solid fill whatever the variant, so pair pressed with outline or ghost: a solid button looks the same pressed or not.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/button.tsx132 lines
import type { ComponentProps, ReactNode } from "react";
import "./button.css";

type ButtonOwnProps = {
  /** Visual weight. Use `accent` for the one primary action on a screen; `quiet` is a ghost in secondary ink. */
  variant?: "solid" | "accent" | "outline" | "ghost" | "quiet" | "danger";
  /** Control height. `xs` is 24px, for an action inside a dense row. */
  size?: "xs" | "s" | "m" | "l";
  /** Shows a spinner and sets aria-busy and aria-disabled. It stays focusable but does not act. */
  loading?: boolean;
  /** Icon or other content placed before the label. */
  lead?: ReactNode;
  /** Icon, shortcut or other content placed after the label. */
  trail?: ReactNode;
  /** Icon only: as wide as it is tall. Give it an aria-label. */
  square?: boolean;
};

export type ButtonAsButtonProps = ComponentProps<"button"> &
  ButtonOwnProps & {
    /** Leave out for a button. Given, the part renders a link. */
    href?: undefined;
    /** Makes it a toggle button: sets aria-pressed and draws the pressed look when true. */
    pressed?: boolean;
  };

export type ButtonAsLinkProps = Omit<ComponentProps<"a">, "href"> &
  ButtonOwnProps & {
    /** Renders a real link that looks like the button. */
    href: string;
    /** A link has no disabled attribute: this drops the href and sets aria-disabled. */
    disabled?: boolean;
    pressed?: never;
    type?: never;
  };

export type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps;

function isOn(value: unknown) {
  return value === true || value === "true";
}

export function Button(props: ButtonAsLinkProps): ReactNode;
export function Button(props: ButtonAsButtonProps): ReactNode;
export function Button(props: ButtonProps) {
  const { variant = "solid", size = "m", loading = false, lead, trail, square, className, children } = props;
  const look = {
    className: ["eq-button", className].filter(Boolean).join(" "),
    "data-variant": variant,
    "data-size": size,
    "data-square": square || undefined,
    "aria-busy": loading || undefined,
  };
  const inside = (
    <>
      {loading ? <span className="eq-button-spinner" aria-hidden="true" /> : lead}
      {children != null && <span className="eq-button-label">{children}</span>}
      {trail}
    </>
  );

  if (props.href !== undefined) {
    const {
      variant: _v,
      size: _s,
      loading: _l,
      lead: _le,
      trail: _t,
      square: _sq,
      pressed: _p,
      type: _ty,
      className: _c,
      children: _ch,
      href,
      disabled,
      onClick,
      ...rest
    } = props;
    // A link that cannot be followed loses its href, so neither a click nor Enter navigates.
    const blocked = disabled || loading || isOn(rest["aria-disabled"]);
    return (
      <a
        {...rest}
        {...(blocked ? { role: "link", "aria-disabled": true } : { href, onClick })}
        // Loading keeps the place in the tab order it had; disabled leaves it, like a disabled button.
        {...(loading && !disabled ? { tabIndex: rest.tabIndex ?? 0 } : {})}
        {...look}
      >
        {inside}
      </a>
    );
  }

  const {
    variant: _v,
    size: _s,
    loading: _l,
    lead: _le,
    trail: _t,
    square: _sq,
    href: _h,
    className: _c,
    children: _ch,
    pressed,
    disabled,
    type = "button",
    onClick,
    formAction,
    popoverTarget,
    popoverTargetAction,
    // Invoker commands, for engines and React types that have them.
    command,
    commandfor,
    ...rest
  } = props as ButtonAsButtonProps & { command?: string; commandfor?: string };
  // aria-disabled from the caller or from loading keeps focus where it is; the button just does nothing.
  const blocked = loading || isOn(rest["aria-disabled"]);
  return (
    <button
      {...rest}
      // Only when given, so an aria-pressed passed in rest still stands.
      {...(pressed === undefined ? {} : { "aria-pressed": pressed })}
      {...(blocked
        ? { type: "button" as const, "aria-disabled": true }
        : { type, onClick, formAction, popoverTarget, popoverTargetAction, command, commandfor })}
      {...look}
      disabled={disabled}
    >
      {inside}
    </button>
  );
}
components/equip/button.css164 lines
@layer equip {
  .eq-button {
    --_bg: var(--eq-solid);
    box-sizing: border-box;
    --_ink: var(--eq-solid-ink);
    --_line: transparent;

    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-2);
    height: var(--eq-h-m);
    padding: 0 var(--eq-space-3);
    border: 1px solid var(--_line);
    border-radius: var(--eq-radius-m);
    background: var(--_bg);
    color: var(--_ink);
    font: var(--eq-weight-strong) var(--eq-text-m) / 1 var(--eq-font);
    letter-spacing: -0.005em;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-button[data-size="s"] {
    height: var(--eq-h-s);
    padding-inline: var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    font-size: var(--eq-text-s);
    gap: var(--eq-space-1);
  }

  .eq-button[data-size="l"] {
    height: var(--eq-h-l);
    padding-inline: var(--eq-space-4);
    font-size: var(--eq-text-l);
  }

  /* Extra small: the 24px minimum target, for an action inside a dense row such as a file, a reaction or a notification. */
  .eq-button[data-size="xs"] {
    height: 24px;
    padding-inline: 6px;
    border-radius: var(--eq-radius-s);
    font-size: var(--eq-text-xs);
    gap: var(--eq-space-1);
  }

  /* Icon only: as wide as it is tall, at every size. */
  .eq-button[data-square] {
    width: var(--eq-h-m);
    padding-inline: 0;
  }

  .eq-button[data-square][data-size="xs"] {
    width: 24px;
  }

  .eq-button[data-square][data-size="s"] {
    width: var(--eq-h-s);
  }

  .eq-button[data-square][data-size="l"] {
    width: var(--eq-h-l);
  }

  .eq-button[data-variant="accent"] {
    --_bg: var(--eq-accent);
    --_ink: var(--eq-accent-ink);
    /* The accent has a weak edge on a pale surface, so the ink draws one. */
    --_line: color-mix(in srgb, var(--eq-accent-ink) 55%, var(--eq-accent));
  }

  .eq-button[data-variant="outline"] {
    --_bg: transparent;
    --_ink: var(--eq-ink);
    --_line: var(--eq-line-strong);
  }

  .eq-button[data-variant="ghost"] {
    --_bg: transparent;
    --_ink: var(--eq-ink);
  }

  /* A ghost in secondary ink, for actions that should not compete with the text beside them. */
  .eq-button[data-variant="quiet"] {
    --_bg: transparent;
    --_ink: var(--eq-ink-2);
  }

  .eq-button[data-variant="danger"] {
    --_bg: var(--eq-danger);
    --_ink: var(--eq-solid-ink);
  }

  /* Pressed is the shared "on" state, as on a toggle group item: the solid fill, whatever the variant. */
  .eq-button[aria-pressed="true"] {
    --_bg: var(--eq-solid);
    --_ink: var(--eq-solid-ink);
    --_line: transparent;
  }

  @media (hover: hover) {
    .eq-button:hover:not(:disabled, [aria-disabled="true"]) {
      background: color-mix(in srgb, var(--_bg) 88%, var(--_ink));
    }

    .eq-button:is([data-variant="outline"], [data-variant="ghost"], [data-variant="quiet"]):hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) {
      background: var(--eq-tint);
    }
  }

  .eq-button:active:not(:disabled, [aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* aria-disabled is disabled that keeps focus: loading, a link that cannot be followed, or set by you. */
  .eq-button:is(:disabled, [aria-disabled="true"]) {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-button[aria-busy="true"] {
    opacity: 0.75;
    cursor: progress;
  }

  .eq-button > svg {
    flex: none;
    width: 1.15em;
    height: 1.15em;
  }

  .eq-button-spinner {
    flex: none;
    width: 1em;
    height: 1em;
    border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
    border-top-color: currentColor;
    border-radius: var(--eq-radius-full);
    animation: eq-button-spin 700ms linear infinite;
  }

  @keyframes eq-button-spin {
    to {
      rotate: 1turn;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .eq-button-spinner {
      animation-duration: 2.4s;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index