Skip to content
Star on GitHub
Forms and inputsButtons
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Buttons

Every button style, with loading, success and hold-to-confirm states. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add buttons
Styles
With icon
States
Sizes
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add buttons

React

$npx designme add buttons --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Buttons is a set of pill and icon buttons that show what an action is doing while it runs. A button can go busy, then confirm it is done, a run button shows its elapsed time and turns into Stop, and a destructive action can require a hold to confirm. This matters in an agent product, where a click often starts work that takes a few seconds.

  • When a click starts agent work and the person should see it is in progress and then finished
  • When a run needs a single control that starts it, shows how long it has gone and stops it
  • When a destructive action should take a deliberate hold instead of a single click
  • When a Share or More button should open a menu and then say what happened
03

Usage

Import it and drive it from your own state.

Import

import { Buttons } from '@/components/designme/buttons/Buttons';

Example

The demo above as a React component, 87 lines you can copy.

'use client';
// The button sheet from designme.dev, rebuilt from the React buttons. Each one shows a state you drive yourself:
// a promise for busy and done, a toggle, a menu that reports back, a run you can stop, a hold to confirm.
import { useEffect, useRef, useState } from 'react';
import { useWait } from '../lib/useScript';
import { Button, ButtonCopy, ButtonHold, ButtonIconMenu, ButtonMenu, ButtonRun, Buttons, type ButtonRunState } from './Buttons';

const LINK = 'https://designme.dev/p/pricing';

export default function ButtonsDemo() {
  const wait = useWait();
  const [preview, setPreview] = useState(false);

  // Run agent counts while it runs and finishes after 3.2s; clicking it while it runs stops it
  const [run, setRun] = useState<ButtonRunState>('idle');
  const [secs, setSecs] = useState(0);
  useEffect(() => {
    if (run !== 'running') return;
    const tick = setInterval(() => setSecs(s => s + 1), 1000), done = setTimeout(() => setRun('complete'), 3200);
    return () => { clearInterval(tick); clearTimeout(done); };
  }, [run]);
  useEffect(() => {
    if (run !== 'complete' && run !== 'stopped') return;
    const t = setTimeout(() => setRun('idle'), run === 'complete' ? 1800 : 1200);
    return () => clearTimeout(t);
  }, [run]);

  // Share says what happened, then goes back to its label
  const [share, setShare] = useState<{ label: string; busy?: boolean } | null>(null);
  const shareT = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(shareT.current), []);
  const said = (label: string) => { setShare({ label }); clearTimeout(shareT.current); shareT.current = setTimeout(() => setShare(null), 1600); };
  const pick = (key: string) => {
    if (key === 'link') { try { navigator.clipboard?.writeText(LINK).catch(() => {}); } catch { /* no clipboard here */ } said('Link copied'); }
    else if (key === 'slack') said('Sent to #design');
    else { clearTimeout(shareT.current); setShare({ label: 'Exporting', busy: true }); shareT.current = setTimeout(() => said('PDF ready'), 1100); }
  };

  // More says what it did for a moment
  const [more, setMore] = useState<string | null>(null);
  const moreT = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(moreT.current), []);
  const did = (key: string) => {
    const said: Record<string, string> = { duplicate: 'Duplicated', rename: 'Renamed', move: 'Moved to Website', delete: 'Moved to trash' };
    clearTimeout(moreT.current);
    moreT.current = setTimeout(() => { setMore(said[key]); moreT.current = setTimeout(() => setMore(null), 1500); }, 140);
  };

  return (
    <Buttons rows={[
      { label: 'Styles', children: <>
        <Button variant="accent" className="dm-btx-trial" busyLabel="Starting" doneLabel="Trial started" onClick={() => wait(1100)}>Start free trial</Button>
        <ButtonRun state={run} seconds={secs} onRun={() => { setSecs(0); setRun('running'); }} onStop={() => setRun('stopped')} />
        <Button variant="soft" className="dm-btx-pv" pressed={preview} onClick={() => setPreview(p => !p)}>{preview ? <><span className="dm-btx-live" />Previewing</> : 'Preview'}</Button>
        <ButtonMenu label="Share" className="dm-btx-share" status={share} onSelect={pick} items={[
          { key: 'link', icon: 'link', label: 'Copy link' },
          { key: 'slack', brand: 'slack', label: 'Send to Slack', detail: '#design' },
          { key: 'pdf', icon: 'down', label: 'Export as PDF' }
        ]} />
        <Button variant="ghost">Cancel</Button>
      </> },
      { label: 'With icon', children: <>
        <Button variant="accent" className="dm-btx-ask" icon="spark" busyGlyph="think" busyLabel="Agent is working" doneLabel="Draft ready" fadeBack={false} onClick={() => wait(2200)}>Ask agent</Button>
        <Button variant="soft" icon="figma" className="dm-btx-fig" busyLabel="Opening" doneLabel="Opened in Figma" doneIcon="ext" onClick={() => wait(900)}>Open in Figma</Button>
        <ButtonCopy text={LINK} className="dm-btx-icp" />
        <ButtonIconMenu label="More" status={more} onSelect={did} items={[
          { key: 'duplicate', icon: 'copy', label: 'Duplicate', detail: '⌘D' },
          { key: 'rename', icon: 'edit', label: 'Rename' },
          { key: 'move', icon: 'folder', label: 'Move to…' },
          { key: 'delete', icon: 'trash', label: 'Delete', detail: '⌫', danger: true, divider: true }
        ]} />
      </> },
      { label: 'States', children: <>
        <Button variant="primary" className="dm-btx-load" success fadeBack={false} busyLabel="Publishing" doneLabel="Published" onClick={() => wait(1300)}>Publish</Button>
        <ButtonCopy text={LINK} label="Copy link" />
        <ButtonHold />
        <Button variant="accent" disabled>Disabled</Button>
      </> },
      { label: 'Sizes', children: <>
        <Button variant="accent" size="sm">Small</Button>
        <Button variant="accent">Default</Button>
        <Button variant="accent" size="lg">Large</Button>
      </> }
    ]} />
  );
}
04

Props

Everything Buttons takes.

ButtonsProps

PropTypeWhat it does
rowsrequiredButtonsRow[]
classNamestring

ButtonProps

primary is the main action, on the accent. ink is the old name for primary and still works */ export type ButtonVariant = 'primary' | 'accent' | 'soft' | 'line' | 'ghost' | 'ink'; /** The class for a variant: ink, the old name, draws as primary */ const variantClass = (v?: ButtonVariant) => v && 'dm-' + (v === 'ink' ? 'primary' : v); export type ButtonSize = 'sm' | 'md' | 'lg'; // a button used on its own still needs the resets that [data-dm] gives its contents, without adding a box to the layout const ROOT = { display: 'contents' } as const; // the content of a button sits in one span, so it can blur out and back in as a whole, like the site's swap() const FACE = { display: 'inline-flex', alignItems: 'center', gap: 7 } as const; /** Shows `children`. When `id` changes to a face that should `fade`, the old one blurs out first; any other change is instant */ function Face({ id, fade, children }: { id: string; fade: boolean; children: ReactNode }) { const [cur, setCur] = useState<{ id: string; node: ReactNode }>({ id, node: children }); const [out, setOut] = useState(false); const reduce = useReducedMotion(); if (id !== cur.id && !fade) setCur({ id, node: children }); useEffect(() => { if (id === cur.id || !fade) return; setOut(true); const t = setTimeout(() => { setCur({ id, node: children }); setOut(false); }, reduce ? 0 : 170); return () => clearTimeout(t); }, [id, fade]); // eslint-disable-line react-hooks/exhaustive-deps return <span className={cx('dm-sw', out && 'dm-out')} style={FACE}>{id === cur.id ? children : cur.node}</span>; } const glyph = (icon: IconName | ReactElement | undefined) => icon === undefined ? null : isValidElement(icon) ? icon : <Icon name={icon as IconName} size="xs" />; const Check = () => <Icon name="check" size="xs" className="dm-enter" />; /* ---------- Button ----------

PropTypeWhat it does
variantButtonVariantprimary (or accent, which looks the same) for the main action, soft and line for the rest, ghost for the quietest. Left out, a plain pill. ink is the old name for primary
sizeButtonSize
iconIconName | ReactElementAn icon before the label: an icon name, or your own element (like a Brand)
childrenReactNode
onClick(e: MouseEvent<HTMLButtonElement>) => void | Promise<unknown>Return a promise and the button runs by itself: busy until it settles, then done for `doneFor` ms
busybooleanShows the busy face: a spinner (or the thinking dots) and `busyLabel`. Clicks are ignored
donebooleanShows the done face: a check that pops in and `doneLabel`. Clicks are ignored
busyLabelReactNodeThe label while busy, like "Publishing". Defaults to the label
doneLabelReactNodeThe label once done, like "Published". Defaults to the label
doneIconIconName | ReactElementThe icon on the done face, like "ext" for something that opened elsewhere. Defaults to a check that pops in
busyGlyph'spin' | 'think'spin, or think: the agent's four turning dots with a shimmering label
successbooleanThe done face turns green
doneFornumberHow long the done face stays when the button runs itself
fadeBackbooleanGoing back from done to the label blurs across (the default) instead of switching at once
pressedbooleanFor a toggle: whether it is on, as aria-pressed
tipstringA tooltip, with an optional shortcut after it
kbdstring

Also takes the props of Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'onClick'>.

ButtonIconProps

primary (or accent, which looks the same) for the main action, soft and line for the rest, ghost for the quietest. Left out, a plain pill. ink is the old name for primary */ variant?: ButtonVariant; size?: ButtonSize; /** An icon before the label: an icon name, or your own element (like a Brand) */ icon?: IconName | ReactElement; children?: ReactNode; /** Return a promise and the button runs by itself: busy until it settles, then done for `doneFor` ms */ onClick?: (e: MouseEvent<HTMLButtonElement>) => void | Promise<unknown>; /** Shows the busy face: a spinner (or the thinking dots) and `busyLabel`. Clicks are ignored */ busy?: boolean; /** Shows the done face: a check that pops in and `doneLabel`. Clicks are ignored */ done?: boolean; /** The label while busy, like "Publishing". Defaults to the label */ busyLabel?: ReactNode; /** The label once done, like "Published". Defaults to the label */ doneLabel?: ReactNode; /** The icon on the done face, like "ext" for something that opened elsewhere. Defaults to a check that pops in */ doneIcon?: IconName | ReactElement; /** spin, or think: the agent's four turning dots with a shimmering label */ busyGlyph?: 'spin' | 'think'; /** The done face turns green */ success?: boolean; /** How long the done face stays when the button runs itself */ doneFor?: number; /** Going back from done to the label blurs across (the default) instead of switching at once */ fadeBack?: boolean; /** For a toggle: whether it is on, as aria-pressed */ pressed?: boolean; /** A tooltip, with an optional shortcut after it */ tip?: string; kbd?: string; } /** A pill button in one of the site's five styles and three sizes, with busy, done and pressed states. */ export function Button({ variant, size = 'md', icon, children, onClick, busy, done, busyLabel, doneLabel, doneIcon, busyGlyph = 'spin', success, doneFor = 1800, fadeBack = true, pressed, tip, kbd, className, ...rest }: ButtonProps) { // an onClick that returns a promise drives the button itself const [auto, setAuto] = useState<'idle' | 'busy' | 'done'>('idle'); const alive = useRef(true), timer = useRef<ReturnType<typeof setTimeout>>(undefined); useEffect(() => { alive.current = true; return () => { alive.current = false; clearTimeout(timer.current); }; }, []); const isBusy = busy ?? auto === 'busy', isDone = !isBusy && (done ?? auto === 'done'); const face = isBusy ? 'busy' : isDone ? 'done' : 'idle'; const click = (e: MouseEvent<HTMLButtonElement>) => { if (isBusy || isDone) return; const r = onClick?.(e); if (!r || typeof r.then !== 'function') return; setAuto('busy'); r.then(() => { if (!alive.current) return; setAuto('done'); timer.current = setTimeout(() => setAuto('idle'), doneFor); }, () => { if (alive.current) setAuto('idle'); }); }; const think = isBusy && busyGlyph === 'think'; const label = isBusy ? busyLabel ?? children : isDone ? doneLabel ?? children : children; const button = ( <button type="button" {...rest} className={cx('dm-pill', variantClass(variant), size === 'sm' && 'dm-sm', size === 'lg' && 'dm-btx-lg', isBusy && 'dm-busy', isDone && 'dm-done', isDone && success && 'dm-btx-load dm-ok', className)} aria-busy={isBusy || undefined} aria-pressed={pressed} onClick={click}> <Face id={face} fade={fadeBack && face === 'idle'}> {isBusy ? (think ? <Think /> : <Spin />) : isDone ? (doneIcon === undefined ? <Check /> : <span className="dm-enter" style={FACE}>{glyph(doneIcon)}</span>) : glyph(icon)} {think ? <span className="dm-shim">{label}</span> : label} </Face> </button> ); return <span data-dm="" style={ROOT}>{tip ? <Tip label={tip} kbd={kbd}>{button}</Tip> : button}</span>; } /* ---------- ButtonIcon ----------

PropTypeWhat it does
iconrequiredIconName
labelrequiredstringRead out and shown in the tooltip
kbdstringA shortcut shown after the label in the tooltip
fillbooleanThe filled round style (the default), or the bare one
iconSize'xs' | 's' | 'm' | 'l'

Also takes the props of Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>.

ButtonCopyProps

Read out and shown in the tooltip */ label: string; /** A shortcut shown after the label in the tooltip */ kbd?: string; /** The filled round style (the default), or the bare one */ fill?: boolean; iconSize?: 'xs' | 's' | 'm' | 'l'; } /** A round icon-only button with a tooltip. */ export function ButtonIcon({ icon, label, kbd, fill = true, iconSize = 'xs', className, ...rest }: ButtonIconProps) { return ( <span data-dm="" style={ROOT}> <Tip label={label} kbd={kbd}> <button type="button" {...rest} className={cx('dm-ib', fill && 'dm-fill', className)} aria-label={label}><Icon name={icon} size={iconSize} /></button> </Tip> </span> ); } /* ---------- ButtonCopy ----------

PropTypeWhat it does
textrequiredstringWhat goes on the clipboard
labelstringThe pill's label, like "Copy link". Left out, it is a round icon button
copiedLabelstringThe label for a moment after copying
tipstringThe icon button's tooltip and name
variantButtonVariant
onCopy(text: string) => void
classNamestring

ButtonIconMenuProps

What goes on the clipboard */ text: string; /** The pill's label, like "Copy link". Left out, it is a round icon button */ label?: string; /** The label for a moment after copying */ copiedLabel?: string; /** The icon button's tooltip and name */ tip?: string; variant?: ButtonVariant; onCopy?: (text: string) => void; className?: string; } /** Copies text, then flashes a check and says so for a moment, like every copy button on the site. */ export function ButtonCopy({ text, label, copiedLabel = 'Copied', tip = 'Copy', variant = 'soft', onCopy, className }: ButtonCopyProps) { const [copied, copy] = useCopied(); const click = () => { copy(text); onCopy?.(text); }; const mark = <Icon name={copied ? 'check' : 'copy'} size="xs" />; return ( <span data-dm="" style={ROOT}> {label === undefined ? <Tip label={copied ? 'Copied to clipboard' : tip} open={copied || undefined}> <button type="button" className={cx('dm-ib dm-fill', copied && 'dm-copied', className)} aria-label={tip} onClick={click}>{mark}</button> </Tip> : <Tip label="Copied to clipboard" open={copied || undefined} off={!copied}> <button type="button" className={cx('dm-pill', variantClass(variant), 'dm-btx-copy', copied && 'dm-copied', className)} onClick={click}>{mark}{copied ? copiedLabel : label}</button> </Tip>} </span> ); } /* ---------- ButtonMenu ---------- */ export interface ButtonMenuItem { key: string; label: string; /** An icon before the label, or `brand` for a service's own mark */ icon?: IconName; brand?: BrandName; /** Quiet text at the end of the row, like "#design" or a shortcut */ detail?: string; /** A destructive action, like Delete: drawn in the danger colour */ danger?: boolean; /** Starts a new group: a line above this item */ divider?: boolean; } function MenuItems({ items, onPick }: { items: ButtonMenuItem[]; onPick: (key: string) => void }) { return <>{items.map(it => ( <Fragment key={it.key}> {it.divider && <div className="dm-sep" role="separator" />} <button type="button" role="menuitem" className={cx('dm-mi', it.danger && 'dm-del')} onClick={() => onPick(it.key)}> {it.brand ? <Brand name={it.brand} /> : it.icon && <Icon name={it.icon} />} {it.label} {it.detail && <span className="dm-r">{it.detail}</span>} </button> </Fragment> ))}</>; } export interface ButtonMenuProps { label: string; items: ButtonMenuItem[]; /** Called with the picked item's key. The menu closes a moment later */ onSelect?: (key: string) => void; /** What the button says after a pick, like { label: 'Link copied' }, or { label: 'Exporting', busy: true }. Clear it and the label comes back */ status?: { label: string; busy?: boolean } | null; variant?: ButtonVariant; /** Which edge of the button the menu lines up with */ align?: 'start' | 'end'; /** Whether the menu is open. Leave it out to let the button toggle it */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } /** A button that opens a small menu and then says what happened, like Share. */ export function ButtonMenu({ label, items, onSelect, status, variant = 'line', align = 'start', open, defaultOpen, onOpenChange, className }: ButtonMenuProps) { const menu = usePopover({ align, open, defaultOpen, onOpenChange }); const face = status ? (status.busy ? 'busy:' : 'done:') + status.label : 'idle'; return ( <span data-dm="" style={{ position: 'relative', display: 'inline-flex' }}> <button type="button" {...menu.trigger} className={cx('dm-pill', variantClass(variant), className)} aria-busy={status?.busy || undefined}> <Face id={face} fade={!status}> {status && (status.busy ? <Spin /> : <Check />)} {status ? status.label : label} </Face> </button> <div {...menu.pop} className={cx(menu.pop.className, 'dm-btx-sp')} role="menu"> <MenuItems items={items} onPick={key => { onSelect?.(key); menu.closeSoon(); }} /> </div> </span> ); } /* ---------- ButtonIconMenu ----------

PropTypeWhat it does
iconIconNameThe icon on the button. Defaults to the three dots
labelrequiredstringRead out and shown in the tooltip
itemsrequiredButtonMenuItem[]
onSelect(key: string) => voidCalled with the picked item's key. The menu closes a moment later
statusstring | nullWhat just happened, like "Duplicated": the icon turns into a check and the tooltip says it. Clear it and the icon comes back
align'start' | 'end'Which edge of the button the menu lines up with
openbooleanWhether the menu is open. Leave it out to let the button toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring

ButtonHoldProps

The icon on the button. Defaults to the three dots */ icon?: IconName; /** Read out and shown in the tooltip */ label: string; items: ButtonMenuItem[]; /** Called with the picked item's key. The menu closes a moment later */ onSelect?: (key: string) => void; /** What just happened, like "Duplicated": the icon turns into a check and the tooltip says it. Clear it and the icon comes back */ status?: string | null; /** Which edge of the button the menu lines up with */ align?: 'start' | 'end'; /** Whether the menu is open. Leave it out to let the button toggle it */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } /** A round icon button that opens a menu of actions, like More, then flashes a check and says what happened. */ export function ButtonIconMenu({ icon = 'more', label, items, onSelect, status, align = 'end', open, defaultOpen, onOpenChange, className }: ButtonIconMenuProps) { const menu = usePopover({ align, open, defaultOpen, onOpenChange }); return ( <span data-dm="" style={{ position: 'relative', display: 'inline-flex' }}> {/* no tooltip over its own open menu */} <Tip label={status || label} open={status ? true : undefined} off={menu.open && !status}> <button type="button" {...menu.trigger} className={cx('dm-ib dm-fill dm-btx-more', status && 'dm-done', className)} aria-label={label}> <Icon name={status ? 'check' : icon} size="xs" /> </button> </Tip> <div {...menu.pop} className={cx(menu.pop.className, 'dm-btx-mp')} role="menu"> <MenuItems items={items} onPick={key => { onSelect?.(key); menu.closeSoon(); }} /> </div> </span> ); } /* ---------- ButtonRun ---------- */ export type ButtonRunState = 'idle' | 'running' | 'stopped' | 'complete'; export interface ButtonRunProps { state: ButtonRunState; /** How long it has been running, shown as m:ss */ seconds?: number; label?: string; /** Clicked while not running */ onRun?: () => void; /** Clicked while running. Hovering a running button already reads Stop */ onStop?: () => void; variant?: ButtonVariant; className?: string; } const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s) % 60).padStart(2, '0')}`; /** Starts a run and shows its time; while it runs, hovering turns it into Stop. */ export function ButtonRun({ state, seconds = 0, label = 'Run agent', onRun, onStop, variant = 'primary', className }: ButtonRunProps) { const [hover, setHover] = useState(false); const running = state === 'running'; const face = running ? (hover ? 'stop' : 'run') : state; return ( <span data-dm="" style={ROOT}> <button type="button" className={cx('dm-pill', variantClass(variant), 'dm-btx-run', running && 'dm-busy', className)} aria-busy={running || undefined} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} onClick={() => (running ? onStop : onRun)?.()}> <Face id={face} fade={state === 'idle'}> {face === 'stop' || face === 'stopped' ? <span className="dm-sq" /> : face === 'run' ? <Spin /> : face === 'complete' ? <Check /> : null} {face === 'stop' ? 'Stop' : face === 'stopped' ? 'Stopped' : face === 'complete' ? 'Run complete' : face === 'run' ? 'Running' : label} {face === 'run' && <span className="dm-num">{clock(seconds)}</span>} </Face> </button> </span> ); } /* ---------- ButtonHold ----------

PropTypeWhat it does
labelstring
holdingLabelstringShown while it is held
doneLabelstringShown once the hold completes
iconIconName
onConfirm() => voidCalled when it has been held for 1.2s, with a pointer or with Space or Enter
resetAfternumberHow long the done face stays before it resets
classNamestring
05

Code

The full source, as the CLI installs it.

Buttons.tsxReact · 387 lines
'use client';
import { Fragment, isValidElement, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactElement, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useCopied, useReducedMotion } from '../lib/hooks';
import { Brand, type BrandName } from '../lib/brands';
import { Icon, type IconName } from '../lib/icons';
import { Spin, Think, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './buttons.css';

/** primary is the main action, on the accent. ink is the old name for primary and still works */
export type ButtonVariant = 'primary' | 'accent' | 'soft' | 'line' | 'ghost' | 'ink';
/** The class for a variant: ink, the old name, draws as primary */
const variantClass = (v?: ButtonVariant) => v && 'dm-' + (v === 'ink' ? 'primary' : v);
export type ButtonSize = 'sm' | 'md' | 'lg';

// a button used on its own still needs the resets that [data-dm] gives its contents, without adding a box to the layout
const ROOT = { display: 'contents' } as const;
// the content of a button sits in one span, so it can blur out and back in as a whole, like the site's swap()
const FACE = { display: 'inline-flex', alignItems: 'center', gap: 7 } as const;

/** Shows `children`. When `id` changes to a face that should `fade`, the old one blurs out first; any other change is instant */
function Face({ id, fade, children }: { id: string; fade: boolean; children: ReactNode }) {
  const [cur, setCur] = useState<{ id: string; node: ReactNode }>({ id, node: children });
  const [out, setOut] = useState(false);
  const reduce = useReducedMotion();
  if (id !== cur.id && !fade) setCur({ id, node: children });
  useEffect(() => {
    if (id === cur.id || !fade) return;
    setOut(true);
    const t = setTimeout(() => { setCur({ id, node: children }); setOut(false); }, reduce ? 0 : 170);
    return () => clearTimeout(t);
  }, [id, fade]); // eslint-disable-line react-hooks/exhaustive-deps
  return <span className={cx('dm-sw', out && 'dm-out')} style={FACE}>{id === cur.id ? children : cur.node}</span>;
}

const glyph = (icon: IconName | ReactElement | undefined) =>
  icon === undefined ? null : isValidElement(icon) ? icon : <Icon name={icon as IconName} size="xs" />;
const Check = () => <Icon name="check" size="xs" className="dm-enter" />;

/* ---------- Button ---------- */

export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'onClick'> {
  /** primary (or accent, which looks the same) for the main action, soft and line for the rest, ghost for the quietest. Left out, a plain pill. ink is the old name for primary */
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** An icon before the label: an icon name, or your own element (like a Brand) */
  icon?: IconName | ReactElement;
  children?: ReactNode;
  /** Return a promise and the button runs by itself: busy until it settles, then done for `doneFor` ms */
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
  /** Shows the busy face: a spinner (or the thinking dots) and `busyLabel`. Clicks are ignored */
  busy?: boolean;
  /** Shows the done face: a check that pops in and `doneLabel`. Clicks are ignored */
  done?: boolean;
  /** The label while busy, like "Publishing". Defaults to the label */
  busyLabel?: ReactNode;
  /** The label once done, like "Published". Defaults to the label */
  doneLabel?: ReactNode;
  /** The icon on the done face, like "ext" for something that opened elsewhere. Defaults to a check that pops in */
  doneIcon?: IconName | ReactElement;
  /** spin, or think: the agent's four turning dots with a shimmering label */
  busyGlyph?: 'spin' | 'think';
  /** The done face turns green */
  success?: boolean;
  /** How long the done face stays when the button runs itself */
  doneFor?: number;
  /** Going back from done to the label blurs across (the default) instead of switching at once */
  fadeBack?: boolean;
  /** For a toggle: whether it is on, as aria-pressed */
  pressed?: boolean;
  /** A tooltip, with an optional shortcut after it */
  tip?: string;
  kbd?: string;
}

/** A pill button in one of the site's five styles and three sizes, with busy, done and pressed states. */
export function Button({ variant, size = 'md', icon, children, onClick, busy, done, busyLabel, doneLabel, doneIcon, busyGlyph = 'spin', success, doneFor = 1800,
  fadeBack = true, pressed, tip, kbd, className, ...rest }: ButtonProps) {
  // an onClick that returns a promise drives the button itself
  const [auto, setAuto] = useState<'idle' | 'busy' | 'done'>('idle');
  const alive = useRef(true), timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => { alive.current = true; return () => { alive.current = false; clearTimeout(timer.current); }; }, []);
  const isBusy = busy ?? auto === 'busy', isDone = !isBusy && (done ?? auto === 'done');
  const face = isBusy ? 'busy' : isDone ? 'done' : 'idle';

  const click = (e: MouseEvent<HTMLButtonElement>) => {
    if (isBusy || isDone) return;
    const r = onClick?.(e);
    if (!r || typeof r.then !== 'function') return;
    setAuto('busy');
    r.then(() => { if (!alive.current) return; setAuto('done'); timer.current = setTimeout(() => setAuto('idle'), doneFor); },
      () => { if (alive.current) setAuto('idle'); });
  };

  const think = isBusy && busyGlyph === 'think';
  const label = isBusy ? busyLabel ?? children : isDone ? doneLabel ?? children : children;
  const button = (
    <button type="button" {...rest}
      className={cx('dm-pill', variantClass(variant), size === 'sm' && 'dm-sm', size === 'lg' && 'dm-btx-lg',
        isBusy && 'dm-busy', isDone && 'dm-done', isDone && success && 'dm-btx-load dm-ok', className)}
      aria-busy={isBusy || undefined} aria-pressed={pressed} onClick={click}>
      <Face id={face} fade={fadeBack && face === 'idle'}>
        {isBusy ? (think ? <Think /> : <Spin />) : isDone ? (doneIcon === undefined ? <Check /> : <span className="dm-enter" style={FACE}>{glyph(doneIcon)}</span>) : glyph(icon)}
        {think ? <span className="dm-shim">{label}</span> : label}
      </Face>
    </button>
  );
  return <span data-dm="" style={ROOT}>{tip ? <Tip label={tip} kbd={kbd}>{button}</Tip> : button}</span>;
}

/* ---------- ButtonIcon ---------- */

export interface ButtonIconProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
  icon: IconName;
  /** Read out and shown in the tooltip */
  label: string;
  /** A shortcut shown after the label in the tooltip */
  kbd?: string;
  /** The filled round style (the default), or the bare one */
  fill?: boolean;
  iconSize?: 'xs' | 's' | 'm' | 'l';
}

/** A round icon-only button with a tooltip. */
export function ButtonIcon({ icon, label, kbd, fill = true, iconSize = 'xs', className, ...rest }: ButtonIconProps) {
  return (
    <span data-dm="" style={ROOT}>
      <Tip label={label} kbd={kbd}>
        <button type="button" {...rest} className={cx('dm-ib', fill && 'dm-fill', className)} aria-label={label}><Icon name={icon} size={iconSize} /></button>
      </Tip>
    </span>
  );
}

/* ---------- ButtonCopy ---------- */

export interface ButtonCopyProps {
  /** What goes on the clipboard */
  text: string;
  /** The pill's label, like "Copy link". Left out, it is a round icon button */
  label?: string;
  /** The label for a moment after copying */
  copiedLabel?: string;
  /** The icon button's tooltip and name */
  tip?: string;
  variant?: ButtonVariant;
  onCopy?: (text: string) => void;
  className?: string;
}

/** Copies text, then flashes a check and says so for a moment, like every copy button on the site. */
export function ButtonCopy({ text, label, copiedLabel = 'Copied', tip = 'Copy', variant = 'soft', onCopy, className }: ButtonCopyProps) {
  const [copied, copy] = useCopied();
  const click = () => { copy(text); onCopy?.(text); };
  const mark = <Icon name={copied ? 'check' : 'copy'} size="xs" />;
  return (
    <span data-dm="" style={ROOT}>
      {label === undefined
        ? <Tip label={copied ? 'Copied to clipboard' : tip} open={copied || undefined}>
            <button type="button" className={cx('dm-ib dm-fill', copied && 'dm-copied', className)} aria-label={tip} onClick={click}>{mark}</button>
          </Tip>
        : <Tip label="Copied to clipboard" open={copied || undefined} off={!copied}>
            <button type="button" className={cx('dm-pill', variantClass(variant), 'dm-btx-copy', copied && 'dm-copied', className)} onClick={click}>{mark}{copied ? copiedLabel : label}</button>
          </Tip>}
    </span>
  );
}

/* ---------- ButtonMenu ---------- */

export interface ButtonMenuItem {
  key: string;
  label: string;
  /** An icon before the label, or `brand` for a service's own mark */
  icon?: IconName;
  brand?: BrandName;
  /** Quiet text at the end of the row, like "#design" or a shortcut */
  detail?: string;
  /** A destructive action, like Delete: drawn in the danger colour */
  danger?: boolean;
  /** Starts a new group: a line above this item */
  divider?: boolean;
}

function MenuItems({ items, onPick }: { items: ButtonMenuItem[]; onPick: (key: string) => void }) {
  return <>{items.map(it => (
    <Fragment key={it.key}>
      {it.divider && <div className="dm-sep" role="separator" />}
      <button type="button" role="menuitem" className={cx('dm-mi', it.danger && 'dm-del')} onClick={() => onPick(it.key)}>
        {it.brand ? <Brand name={it.brand} /> : it.icon && <Icon name={it.icon} />}
        {it.label}
        {it.detail && <span className="dm-r">{it.detail}</span>}
      </button>
    </Fragment>
  ))}</>;
}

export interface ButtonMenuProps {
  label: string;
  items: ButtonMenuItem[];
  /** Called with the picked item's key. The menu closes a moment later */
  onSelect?: (key: string) => void;
  /** What the button says after a pick, like { label: 'Link copied' }, or { label: 'Exporting', busy: true }. Clear it and the label comes back */
  status?: { label: string; busy?: boolean } | null;
  variant?: ButtonVariant;
  /** Which edge of the button the menu lines up with */
  align?: 'start' | 'end';
  /** Whether the menu is open. Leave it out to let the button toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

/** A button that opens a small menu and then says what happened, like Share. */
export function ButtonMenu({ label, items, onSelect, status, variant = 'line', align = 'start', open, defaultOpen, onOpenChange, className }: ButtonMenuProps) {
  const menu = usePopover({ align, open, defaultOpen, onOpenChange });
  const face = status ? (status.busy ? 'busy:' : 'done:') + status.label : 'idle';
  return (
    <span data-dm="" style={{ position: 'relative', display: 'inline-flex' }}>
      <button type="button" {...menu.trigger} className={cx('dm-pill', variantClass(variant), className)} aria-busy={status?.busy || undefined}>
        <Face id={face} fade={!status}>
          {status && (status.busy ? <Spin /> : <Check />)}
          {status ? status.label : label}
        </Face>
      </button>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-btx-sp')} role="menu">
        <MenuItems items={items} onPick={key => { onSelect?.(key); menu.closeSoon(); }} />
      </div>
    </span>
  );
}

/* ---------- ButtonIconMenu ---------- */

export interface ButtonIconMenuProps {
  /** The icon on the button. Defaults to the three dots */
  icon?: IconName;
  /** Read out and shown in the tooltip */
  label: string;
  items: ButtonMenuItem[];
  /** Called with the picked item's key. The menu closes a moment later */
  onSelect?: (key: string) => void;
  /** What just happened, like "Duplicated": the icon turns into a check and the tooltip says it. Clear it and the icon comes back */
  status?: string | null;
  /** Which edge of the button the menu lines up with */
  align?: 'start' | 'end';
  /** Whether the menu is open. Leave it out to let the button toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

/** A round icon button that opens a menu of actions, like More, then flashes a check and says what happened. */
export function ButtonIconMenu({ icon = 'more', label, items, onSelect, status, align = 'end', open, defaultOpen, onOpenChange, className }: ButtonIconMenuProps) {
  const menu = usePopover({ align, open, defaultOpen, onOpenChange });
  return (
    <span data-dm="" style={{ position: 'relative', display: 'inline-flex' }}>
      {/* no tooltip over its own open menu */}
      <Tip label={status || label} open={status ? true : undefined} off={menu.open && !status}>
        <button type="button" {...menu.trigger} className={cx('dm-ib dm-fill dm-btx-more', status && 'dm-done', className)} aria-label={label}>
          <Icon name={status ? 'check' : icon} size="xs" />
        </button>
      </Tip>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-btx-mp')} role="menu">
        <MenuItems items={items} onPick={key => { onSelect?.(key); menu.closeSoon(); }} />
      </div>
    </span>
  );
}

/* ---------- ButtonRun ---------- */

export type ButtonRunState = 'idle' | 'running' | 'stopped' | 'complete';

export interface ButtonRunProps {
  state: ButtonRunState;
  /** How long it has been running, shown as m:ss */
  seconds?: number;
  label?: string;
  /** Clicked while not running */
  onRun?: () => void;
  /** Clicked while running. Hovering a running button already reads Stop */
  onStop?: () => void;
  variant?: ButtonVariant;
  className?: string;
}

const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s) % 60).padStart(2, '0')}`;

/** Starts a run and shows its time; while it runs, hovering turns it into Stop. */
export function ButtonRun({ state, seconds = 0, label = 'Run agent', onRun, onStop, variant = 'primary', className }: ButtonRunProps) {
  const [hover, setHover] = useState(false);
  const running = state === 'running';
  const face = running ? (hover ? 'stop' : 'run') : state;
  return (
    <span data-dm="" style={ROOT}>
      <button type="button" className={cx('dm-pill', variantClass(variant), 'dm-btx-run', running && 'dm-busy', className)} aria-busy={running || undefined}
        onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} onClick={() => (running ? onStop : onRun)?.()}>
        <Face id={face} fade={state === 'idle'}>
          {face === 'stop' || face === 'stopped' ? <span className="dm-sq" /> : face === 'run' ? <Spin /> : face === 'complete' ? <Check /> : null}
          {face === 'stop' ? 'Stop' : face === 'stopped' ? 'Stopped' : face === 'complete' ? 'Run complete' : face === 'run' ? 'Running' : label}
          {face === 'run' && <span className="dm-num">{clock(seconds)}</span>}
        </Face>
      </button>
    </span>
  );
}

/* ---------- ButtonHold ---------- */

export interface ButtonHoldProps {
  label?: string;
  /** Shown while it is held */
  holdingLabel?: string;
  /** Shown once the hold completes */
  doneLabel?: string;
  icon?: IconName;
  /** Called when it has been held for 1.2s, with a pointer or with Space or Enter */
  onConfirm?: () => void;
  /** How long the done face stays before it resets */
  resetAfter?: number;
  className?: string;
}

/** A destructive action you hold down to confirm, filling up as you hold. Letting go early cancels it. */
export function ButtonHold({ label = 'Hold to delete', holdingLabel = 'Keep holding', doneLabel = 'Deleted', icon = 'trash', onConfirm, resetAfter = 1800, className }: ButtonHoldProps) {
  const [state, setState] = useState<'idle' | 'holding' | 'done'>('idle');
  // the label blurs out before "Keep holding" comes in
  const [said, setSaid] = useState(false);
  const [out, setOut] = useState(false);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const clear = () => { timers.current.forEach(clearTimeout); timers.current = []; };
  useEffect(() => clear, []);
  const later = (ms: number, fn: () => void) => { timers.current.push(setTimeout(fn, ms)); };

  const start = () => {
    if (state !== 'idle') return;
    setState('holding'); setOut(true);
    later(170, () => { setSaid(true); setOut(false); });
    later(1200, () => {
      setState('done'); setSaid(false); onConfirm?.();
      later(resetAfter, () => setState('idle'));
    });
  };
  const end = () => { if (state !== 'holding') return; clear(); setState('idle'); setSaid(false); setOut(false); };

  return (
    <span data-dm="" style={ROOT}>
      <button type="button" className={cx('dm-pill dm-btx-hold', state === 'holding' && 'dm-h', state === 'done' && 'dm-done', className)}
        onPointerDown={start} onPointerUp={end} onPointerLeave={end} onBlur={end}
        onKeyDown={e => { if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) { e.preventDefault(); start(); } }}
        onKeyUp={e => { if (e.key === ' ' || e.key === 'Enter') end(); }}>
        <span className="dm-fill" />
        <span className={cx('dm-lb', state === 'holding' && 'dm-sw', out && 'dm-out')}>
          {state === 'done' ? <><Icon name="check" size="xs" />{doneLabel}</> : said ? holdingLabel : <><Icon name={icon} size="xs" />{label}</>}
        </span>
      </button>
    </span>
  );
}

/* ---------- Buttons ---------- */

export interface ButtonsRow {
  /** The quiet name on the left, like "States" */
  label: string;
  children: ReactNode;
}

export interface ButtonsProps {
  rows: ButtonsRow[];
  className?: string;
}

/** Labelled rows of buttons, the way designme.dev lays out its button sheet. */
export function Buttons({ rows, className }: ButtonsProps) {
  return (
    <div data-dm="" className={cx('dm-btx', className)}>
      {rows.map((r, i) => <div key={i} className="dm-btx-row"><span className="dm-btx-l dm-muted">{r.label}</span>{r.children}</div>)}
    </div>
  );
}
buttons.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Buttons ---------- */
.dm-btx{display:flex;flex-direction:column;gap:14px;width:100%}
.dm-btx-row{display:flex;align-items:center;gap:var(--dm-s-2);flex-wrap:wrap}
.dm-btx-l{width:74px;font-size:12px;flex:none}
@media (max-width:520px){.dm-btx-l{width:100%}}
.dm-btx-lg{height:40px;padding:0 18px;font-size:14px}
.dm-btx-load,.dm-btx-copy{min-width:100px;transition:min-width .4s var(--dm-ease),background .3s}
.dm-btx-load .dm-spin{width:13px;height:13px;border-color:color-mix(in srgb,var(--dm-on-ink) 30%,transparent);border-top-color:var(--dm-on-ink)}
.dm-pill.dm-btx-load.dm-ok{background:color-mix(in srgb,var(--dm-ok) 16%,var(--dm-surface));color:var(--dm-ok);opacity:1}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-btx-load.dm-ok:hover{background:color-mix(in srgb,var(--dm-ok) 16%,var(--dm-surface));color:var(--dm-ok);opacity:1}}
.dm-btx-load.dm-busy{cursor:progress}
.dm-btx-ask{min-width:110px;position:relative;overflow:hidden;isolation:isolate;transition:min-width .4s var(--dm-ease),background .3s}
.dm-btx-ask::after{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 30%,rgba(255,255,255,.42) 50%,transparent 70%);transform:translateX(-120%);pointer-events:none}
.dm-btx-ask:focus-visible::after{transform:translateX(120%);transition:transform .85s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-btx-ask:hover::after{transform:translateX(120%);transition:transform .85s var(--dm-ease)}}
.dm-btx-ask:is(.dm-busy,.dm-done)::after{display:none}
.dm-btx-trial,.dm-btx-run{min-width:96px;transition:min-width .4s var(--dm-ease),background .3s,opacity .2s}
.dm-btx-trial .dm-spin{border-color:color-mix(in srgb,var(--dm-on-accent) 35%,transparent);border-top-color:var(--dm-on-accent)}
.dm-btx-run .dm-spin{width:13px;height:13px;border-color:color-mix(in srgb,var(--dm-on-ink) 30%,transparent);border-top-color:var(--dm-on-ink)}
.dm-btx-run.dm-busy{cursor:pointer}
.dm-btx-run .dm-sq{width:9px;height:9px;border-radius:2px;background:currentColor;flex:none}
.dm-btx-pv{min-width:84px}
.dm-pill.dm-btx-pv[aria-pressed="true"]{background:var(--dm-soft-h)}
.dm-btx-live{position:relative;width:7px;height:7px;border-radius:50%;background:var(--dm-ok);flex:none}
.dm-btx-live::after{content:"";position:absolute;inset:0;border-radius:50%;background:inherit;animation:dm-ping 1.8s var(--dm-ease) infinite}
.dm-btx-share{min-width:72px}
/* a pill that swap() crossfades keeps its flex layout, so the icon put back in it later keeps its gap */
.dm-btx-row .dm-pill.dm-sw{display:inline-flex}
.dm-btx-sp{min-width:210px}
.dm-btx-fig{min-width:128px;transition:min-width .4s var(--dm-ease),background .3s}
.dm-btx-fig .dm-spin{width:13px;height:13px}
.dm-btx-mp{min-width:200px}
.dm-btx-mp .dm-mi.dm-del,.dm-btx-mp .dm-mi.dm-del .dm-i{color:var(--dm-bad)}
@media (hover:hover) and (pointer:fine){.dm-btx-mp .dm-mi.dm-del:hover{background:var(--dm-bad-bg)}}
.dm-ib.dm-btx-more.dm-done{color:var(--dm-ok)}
.dm-pill.dm-btx-ask.dm-busy{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));cursor:progress}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-btx-ask.dm-busy:hover{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));cursor:progress}}
.dm-pill.dm-btx-ask.dm-done{background:color-mix(in srgb,var(--dm-ok) 16%,var(--dm-surface));color:var(--dm-ok)}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-btx-ask.dm-done:hover{background:color-mix(in srgb,var(--dm-ok) 16%,var(--dm-surface));color:var(--dm-ok)}}
.dm-btx-hold{position:relative;overflow:hidden;background:var(--dm-bad-bg);color:var(--dm-bad);user-select:none;-webkit-user-select:none;touch-action:none}
.dm-btx-hold .dm-fill{position:absolute;inset:0;background:var(--dm-bad);transform-origin:left;transform:scaleX(0);transition:transform .25s var(--dm-ease)}
.dm-btx-hold.dm-h .dm-fill{transform:scaleX(1);transition:transform 1.2s linear}
.dm-btx-hold .dm-lb{position:relative;display:inline-flex;align-items:center;gap:7px;transition:color .3s}
.dm-btx-hold.dm-h .dm-lb,.dm-btx-hold.dm-done .dm-lb{color:#fff}
.dm-btx-hold.dm-done .dm-fill{transform:scaleX(1)}
buttons.htmlHTML · 11 lines
<div class="card wide" data-c="buttons">
  <div class="stage-in">
    <div class="btx">
      <div class="btx-row"><span class="btx-l muted">Styles</span><button class="pill accent btx-trial">Start free trial</button><button class="pill primary btx-run">Run agent</button><button class="pill soft btx-pv" aria-pressed="false">Preview</button><button class="pill line btx-share" data-pop=".btx-sp" aria-haspopup="menu">Share</button><div class="pop btx-sp" role="menu"><button class="mi" role="menuitem" data-close data-a="link"><svg class="i s"><use href="#i-link"/></svg>Copy link</button><button class="mi" role="menuitem" data-close data-a="slack"><svg class="brand" aria-hidden="true" viewBox="0 0 128 128"><path d="M27.255 80.719c0 7.33-5.978 13.317-13.309 13.317C6.616 94.036.63 88.049.63 80.719s5.987-13.317 13.317-13.317h13.309zm6.709 0c0-7.33 5.987-13.317 13.317-13.317s13.317 5.986 13.317 13.317v33.335c0 7.33-5.986 13.317-13.317 13.317-7.33 0-13.317-5.987-13.317-13.317zm0 0" fill="#de1c59"/><path d="M47.281 27.255c-7.33 0-13.317-5.978-13.317-13.309C33.964 6.616 39.951.63 47.281.63s13.317 5.987 13.317 13.317v13.309zm0 6.709c7.33 0 13.317 5.987 13.317 13.317s-5.986 13.317-13.317 13.317H13.946C6.616 60.598.63 54.612.63 47.281c0-7.33 5.987-13.317 13.317-13.317zm0 0" fill="#35c5f0"/><path d="M100.745 47.281c0-7.33 5.978-13.317 13.309-13.317 7.33 0 13.317 5.987 13.317 13.317s-5.987 13.317-13.317 13.317h-13.309zm-6.709 0c0 7.33-5.987 13.317-13.317 13.317s-13.317-5.986-13.317-13.317V13.946C67.402 6.616 73.388.63 80.719.63c7.33 0 13.317 5.987 13.317 13.317zm0 0" fill="#2eb57d"/><path d="M80.719 100.745c7.33 0 13.317 5.978 13.317 13.309 0 7.33-5.987 13.317-13.317 13.317s-13.317-5.987-13.317-13.317v-13.309zm0-6.709c-7.33 0-13.317-5.987-13.317-13.317s5.986-13.317 13.317-13.317h33.335c7.33 0 13.317 5.986 13.317 13.317 0 7.33-5.987 13.317-13.317 13.317zm0 0" fill="#ebb02e"/></svg>Send to Slack<span class="r">#design</span></button><button class="mi" role="menuitem" data-close data-a="pdf"><svg class="i s"><use href="#i-down"/></svg>Export as PDF</button></div><button class="pill ghost">Cancel</button></div>
      <div class="btx-row"><span class="btx-l muted">With icon</span><button class="pill accent btx-ask"><svg class="i xs"><use href="#i-spark"/></svg>Ask agent</button><button class="pill soft btx-fig"><svg class="i xs"><use href="#i-figma"/></svg>Open in Figma</button><button class="ib fill btx-icp" aria-label="Copy" data-tip="Copy"><svg class="i xs"><use href="#i-copy"/></svg></button><button class="ib fill btx-more" aria-label="More" data-tip="More" data-pop=".btx-mp" data-pop-align="end" aria-haspopup="menu"><svg class="i xs"><use href="#i-more"/></svg></button><div class="pop btx-mp" role="menu"><button class="mi" role="menuitem" data-close data-a="Duplicated"><svg class="i s"><use href="#i-copy"/></svg>Duplicate<span class="r">⌘D</span></button><button class="mi" role="menuitem" data-close data-a="Renamed"><svg class="i s"><use href="#i-edit"/></svg>Rename</button><button class="mi" role="menuitem" data-close data-a="Moved to Website"><svg class="i s"><use href="#i-folder"/></svg>Move to…</button><div class="sep"></div><button class="mi del" role="menuitem" data-close data-a="Moved to trash"><svg class="i s"><use href="#i-trash"/></svg>Delete<span class="r">⌫</span></button></div></div>
      <div class="btx-row"><span class="btx-l muted">States</span><button class="pill primary btx-load">Publish</button><button class="pill soft btx-copy"><svg class="i xs"><use href="#i-copy"/></svg>Copy link</button><button class="pill btx-hold"><span class="fill"></span><span class="lb"><svg class="i xs"><use href="#i-trash"/></svg>Hold to delete</span></button><button class="pill accent" disabled>Disabled</button></div>
      <div class="btx-row"><span class="btx-l muted">Sizes</span><button class="pill accent sm">Small</button><button class="pill accent">Default</button><button class="pill accent btx-lg">Large</button></div>
    </div>
  </div>
</div>
buttons.cssHTML · 46 lines
/* ---------- Buttons ---------- */
.btx{display:flex;flex-direction:column;gap:14px;width:100%}
.btx-row{display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
.btx-l{width:74px;font-size:12px;flex:none}
@media (max-width:520px){.btx-l{width:100%}}
.btx-lg{height:40px;padding:0 18px;font-size:14px}
.btx-load,.btx-copy{min-width:100px;transition:min-width .4s var(--ease),background .3s}
.btx-load .spin{width:13px;height:13px;border-color:color-mix(in srgb,var(--on-ink) 30%,transparent);border-top-color:var(--on-ink)}
.pill.btx-load.ok{background:color-mix(in srgb,var(--ok) 16%,var(--surface));color:var(--ok);opacity:1}
@media (hover:hover) and (pointer:fine){.pill.btx-load.ok:hover{background:color-mix(in srgb,var(--ok) 16%,var(--surface));color:var(--ok);opacity:1}}
.btx-load.busy{cursor:progress}
.btx-ask{min-width:110px;position:relative;overflow:hidden;isolation:isolate;transition:min-width .4s var(--ease),background .3s}
.btx-ask::after{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 30%,rgba(255,255,255,.42) 50%,transparent 70%);transform:translateX(-120%);pointer-events:none}
.btx-ask:focus-visible::after{transform:translateX(120%);transition:transform .85s var(--ease)}
@media (hover:hover) and (pointer:fine){.btx-ask:hover::after{transform:translateX(120%);transition:transform .85s var(--ease)}}
.btx-ask:is(.busy,.done)::after{display:none}
.btx-trial,.btx-run{min-width:96px;transition:min-width .4s var(--ease),background .3s,opacity .2s}
.btx-trial .spin{border-color:color-mix(in srgb,var(--on-accent) 35%,transparent);border-top-color:var(--on-accent)}
.btx-run .spin{width:13px;height:13px;border-color:color-mix(in srgb,var(--on-ink) 30%,transparent);border-top-color:var(--on-ink)}
.btx-run.busy{cursor:pointer}
.btx-run .sq{width:9px;height:9px;border-radius:2px;background:currentColor;flex:none}
.btx-pv{min-width:84px}
.pill.btx-pv[aria-pressed="true"]{background:var(--soft-h)}
.btx-live{position:relative;width:7px;height:7px;border-radius:50%;background:var(--ok);flex:none}
.btx-live::after{content:"";position:absolute;inset:0;border-radius:50%;background:inherit;animation:ping 1.8s var(--ease) infinite}
.btx-share{min-width:72px}
/* a pill that swap() crossfades keeps its flex layout, so the icon put back in it later keeps its gap */
.btx-row .pill.sw{display:inline-flex}
.btx-sp{min-width:210px}
.btx-fig{min-width:128px;transition:min-width .4s var(--ease),background .3s}
.btx-fig .spin{width:13px;height:13px}
.btx-mp{min-width:200px}
.btx-mp .mi.del,.btx-mp .mi.del .i{color:var(--bad)}
@media (hover:hover) and (pointer:fine){.btx-mp .mi.del:hover{background:var(--bad-bg)}}
.ib.btx-more.done{color:var(--ok)}
.pill.btx-ask.busy{background:var(--accent-bg);color:var(--accent-fg,var(--accent));cursor:progress}
@media (hover:hover) and (pointer:fine){.pill.btx-ask.busy:hover{background:var(--accent-bg);color:var(--accent-fg,var(--accent));cursor:progress}}
.pill.btx-ask.done{background:color-mix(in srgb,var(--ok) 16%,var(--surface));color:var(--ok)}
@media (hover:hover) and (pointer:fine){.pill.btx-ask.done:hover{background:color-mix(in srgb,var(--ok) 16%,var(--surface));color:var(--ok)}}
.btx-hold{position:relative;overflow:hidden;background:var(--bad-bg);color:var(--bad);user-select:none;-webkit-user-select:none;touch-action:none}
.btx-hold .fill{position:absolute;inset:0;background:var(--bad);transform-origin:left;transform:scaleX(0);transition:transform .25s var(--ease)}
.btx-hold.h .fill{transform:scaleX(1);transition:transform 1.2s linear}
.btx-hold .lb{position:relative;display:inline-flex;align-items:center;gap:7px;transition:color .3s}
.btx-hold.h .lb,.btx-hold.done .lb{color:#fff}
.btx-hold.done .fill{transform:scaleX(1)}
buttons.jsHTML · 64 lines
DM.def('buttons', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{link}', I('link','s')).replace('{slack}', BRAND.slack).replace('{down}', I('down','s')).replace('{spark}', I('spark','xs')).replace('{figma}', I('figma','xs')).replace(/\{copy\}/g, (m, o) => I('copy','xs')).replace('{more}', I('more','xs')).replace('{trash}', I('trash','xs')).replace('{bin}', I('trash','s')).replace('{dup}', I('copy','s')).replace('{edit}', I('edit','s')).replace('{folder}', I('folder','s'));
  const ld = $('.btx-load', r);
  /* busy buttons keep their colour; disabled styling is only for buttons you can't use */
  ld.onclick = () => c.run(async () => { if (ld._busy) return; ld._busy = true; ld.classList.add('busy'); ld.setAttribute('aria-busy', 'true'); ld.innerHTML = '<span class="spin"></span>Publishing'; await c.wait(1300); ld.classList.remove('busy'); ld.classList.add('ok'); ld.innerHTML = I('check','xs') + 'Published'; enter(ld.firstChild); await c.wait(1800); ld.classList.remove('ok'); ld.textContent = 'Publish'; ld._busy = false; ld.removeAttribute('aria-busy'); });
  const ask = $('.btx-ask', r);
  ask.onclick = () => c.run(async () => { if (ask._busy) return; ask._busy = true; ask.classList.add('busy'); ask.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span><span class="shim">Agent is working</span>';
    await c.wait(2200); ask.classList.remove('busy'); ask.classList.add('done'); ask.innerHTML = I('check','xs') + 'Draft ready'; enter(ask.firstChild);
    await c.wait(1800); ask.classList.remove('done'); ask.innerHTML = I('spark','xs') + 'Ask agent'; ask._busy = false; });
  const icp = $('.btx-icp', r); icp.onclick = () => copied(icp, 'https://designme.dev/p/pricing');
  /* Start free trial: a short wait, then a confirmed state */
  const tr = $('.btx-trial', r);
  tr.onclick = () => c.run(async () => { if (tr._busy) return; tr._busy = true; tr.setAttribute('aria-busy', 'true'); tr.innerHTML = '<span class="spin"></span>Starting'; await c.wait(1100); tr.innerHTML = I('check','xs') + 'Trial started'; enter(tr.firstChild); await c.wait(1800); swap(tr, 'Start free trial'); tr._busy = false; tr.removeAttribute('aria-busy'); });
  /* Run agent: shows the run time, turns into Stop while you hover it */
  const rn = $('.btx-run', r); let rt = 0, rtick = null, rdone = null;
  const idle = () => { clearInterval(rtick); clearTimeout(rdone); rn.classList.remove('busy'); rn.removeAttribute('aria-busy'); rn._run = false; };
  const rlabel = () => { if (!rn._run) return; rn.innerHTML = rn.matches(':hover') ? '<span class="sq"></span>Stop' : `<span class="spin"></span>Running <span class="num">${Math.floor(rt / 60)}:${String(rt % 60).padStart(2, '0')}</span>`; };
  rn.onclick = () => {
    if (rn._run){ idle(); rn.innerHTML = '<span class="sq"></span>Stopped'; c.later(1200, () => { if (!rn._run) swap(rn, 'Run agent'); }); return; }
    rn._run = true; rt = 0; rn.classList.add('busy'); rn.setAttribute('aria-busy', 'true'); rlabel();
    rtick = setInterval(() => { rt++; rlabel(); }, 1000);
    rdone = setTimeout(() => { idle(); rn.innerHTML = I('check','xs') + 'Run complete'; enter(rn.firstChild); c.later(1800, () => { if (!rn._run) swap(rn, 'Run agent'); }); }, 3200);
  };
  rn.onmouseenter = rlabel; rn.onmouseleave = rlabel;
  c.offs.push(idle);
  /* Preview: a toggle, live while it is on */
  const pv = $('.btx-pv', r);
  pv.onclick = () => { const on = pv.getAttribute('aria-pressed') !== 'true'; pv.setAttribute('aria-pressed', on); pv.innerHTML = on ? '<span class="btx-live"></span>Previewing' : 'Preview'; };
  /* Share: a small menu, the button says what happened */
  const sh = $('.btx-share', r);
  const said = (t, ms = 1600) => { sh.innerHTML = I('check','xs') + t; enter(sh.firstChild); clearTimeout(sh._t); sh._t = setTimeout(() => swap(sh, 'Share'), ms); };
  $$('.btx-sp .mi', r).forEach(b => b.addEventListener('click', () => {
    const a = b.dataset.a;
    if (a === 'link'){ try { navigator.clipboard.writeText('https://designme.dev/p/pricing').catch(() => {}); } catch(e){} said('Link copied'); }
    else if (a === 'slack') said('Sent to #design');
    else { clearTimeout(sh._t); sh.innerHTML = '<span class="spin"></span>Exporting'; sh._t = setTimeout(() => said('PDF ready'), 1100); }
  }));
  c.offs.push(() => clearTimeout(sh._t));
  /* Open in Figma: a short wait while it opens, then it says where it went */
  const fg = $('.btx-fig', r);
  fg.onclick = () => c.run(async () => { if (fg._busy) return; fg._busy = true; fg.setAttribute('aria-busy', 'true'); fg.innerHTML = '<span class="spin"></span>Opening'; await c.wait(900);
    fg.innerHTML = I('ext','xs') + 'Opened in Figma'; enter(fg.firstChild); await c.wait(1800); swap(fg, I('figma','xs') + 'Open in Figma', true); await c.wait(200); fg._busy = false; fg.removeAttribute('aria-busy'); });
  /* More: a menu of actions; the button flashes a check and says what happened */
  const mo = $('.btx-more', r), moIc = mo.innerHTML;
  // no tooltip over its own open menu
  const mob = new MutationObserver(() => { if (mo.getAttribute('aria-expanded') === 'true'){ if (tipFor === mo) hideTip(true); delete mo.dataset.tip; } else if (!mo.classList.contains('done')) mo.dataset.tip = 'More'; });
  mob.observe(mo, { attributes:true, attributeFilter:['aria-expanded'] }); c.offs.push(() => mob.disconnect());
  $$('.btx-mp .mi', r).forEach(b => b.addEventListener('click', () => {
    clearTimeout(mo._t); c.later(140, () => { mo.innerHTML = I('check','xs'); mo.classList.add('done'); mo.dataset.tip = b.dataset.a; showTip(mo);
      mo._t = setTimeout(() => { mo.innerHTML = moIc; mo.classList.remove('done'); mo.dataset.tip = 'More'; if (tipFor === mo) hideTip(true); }, 1500); });
  }));
  c.offs.push(() => clearTimeout(mo._t));
  const cp = $('.btx-copy', r);
  cp.onclick = () => { copied(cp, 'https://designme.dev/p/pricing'); const t = cp.lastChild; t.textContent = 'Copied'; clearTimeout(cp._lt); cp._lt = setTimeout(() => { t.textContent = 'Copy link'; }, 1600); };
  const h = $('.btx-hold', r), lb = $('.lb', h); let t;
  // the label crossfades to Keep holding by hand, so an early release can cancel it before the text lands
  const start = e => { if (h.classList.contains('done')) return; h.classList.add('h'); lb.classList.add('sw', 'out'); clearTimeout(h._sw); h._sw = setTimeout(() => { lb.textContent = 'Keep holding'; lb.classList.remove('out'); }, REDUCE ? 0 : 170); t = setTimeout(() => { h.classList.remove('h'); h.classList.add('done'); lb.innerHTML = I('check','xs') + 'Deleted'; c.later(1800, () => { h.classList.remove('done'); lb.innerHTML = I('trash','xs') + 'Hold to delete'; }); }, 1200); };
  const end = () => { if (!h.classList.contains('h')) return; clearTimeout(t); clearTimeout(h._sw); lb.classList.remove('out'); h.classList.remove('h'); lb.innerHTML = I('trash','xs') + 'Hold to delete'; };
  h.onpointerdown = start; h.onpointerup = end; h.onpointerleave = end;
  h.onkeydown = e => { if ((e.key === ' ' || e.key === 'Enter') && !e.repeat){ e.preventDefault(); start(); } };
  h.onkeyup = e => { if (e.key === ' ' || e.key === 'Enter') end(); }; h.onblur = end;
  c.offs.push(() => { clearTimeout(t); clearTimeout(h._sw); });
});
06

Questions

What people ask about it.

How do I show a loading state while my request runs?
Return the promise from onClick. The button shows a spinner and busyLabel until it resolves, then a check and doneLabel. If the promise rejects it goes straight back to its label.
Can I drive the busy and done states myself instead of with a promise?
Yes. Pass busy and done directly. While either is true, clicks are ignored.
How does the run button know how long the agent has been running?
You pass seconds and it formats them as m:ss. You also set state to idle, running, stopped or complete, and handle onRun and onStop.
How long does the hold to confirm take?
1.2 seconds of holding with a pointer, Space or Enter. After it confirms, the done face stays for resetAfter ms, 1800 by default.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →