Lunia DS

Base components

Button

A native button in five sizes and five hierarchies that eases its colors on hover, gives a small press, and opens a spinner slot while it works.

Loading preview

A row of the five hierarchies at size md with a leading icon, a size row from xs to xl, and an icon-only button. A Save button turns on loading for 1.6 seconds when clicked. Replay runs that loading cycle on the Save button.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add button

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Button } from './button';

const LOADING_MS = 1600;

function PlusIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.67"
      strokeLinecap="round"
    >
      <path d="M10 4.167v11.666M4.167 10h11.666" />
    </svg>
  );
}

function ArrowRightIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.67"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M4.167 10h11.666M10 4.167 15.833 10 10 15.833" />
    </svg>
  );
}

function SaveIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.67"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M5.833 2.5v3.333h6.667M14.167 17.5v-6.667H5.833V17.5M15.833 17.5H4.167A1.667 1.667 0 0 1 2.5 15.833V4.167A1.667 1.667 0 0 1 4.167 2.5h9.166L17.5 6.667v9.166a1.667 1.667 0 0 1-1.667 1.667Z" />
    </svg>
  );
}

const hierarchies = [
  ['primary', 'Primary'],
  ['secondary', 'Secondary'],
  ['tertiary', 'Tertiary'],
  ['link-color', 'Link color'],
  ['link-gray', 'Link gray'],
] as const;

const destructiveHierarchies = [
  ['primary', 'Delete'],
  ['secondary', 'Delete'],
  ['tertiary', 'Delete'],
  ['link-color', 'Delete'],
] as const;

const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;

export default function ButtonDemo({ replay = 0 }: { replay?: number }) {
  const [saving, setSaving] = React.useState(false);
  const timer = React.useRef<number | undefined>(undefined);

  const save = React.useCallback(() => {
    window.clearTimeout(timer.current);
    setSaving(true);
    timer.current = window.setTimeout(() => setSaving(false), LOADING_MS);
  }, []);

  React.useEffect(() => {
    if (replay > 0) save();
  }, [replay, save]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex flex-wrap items-center justify-center gap-3">
        {hierarchies.map(([hierarchy, label]) => (
          <Button key={hierarchy} hierarchy={hierarchy} iconLeading={<PlusIcon />}>
            {label}
          </Button>
        ))}
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        {destructiveHierarchies.map(([hierarchy, label]) => (
          <Button key={hierarchy} hierarchy={hierarchy} destructive>
            {label}
          </Button>
        ))}
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        {sizes.map((size) => (
          <Button key={size} size={size} hierarchy="secondary" iconTrailing={<ArrowRightIcon />}>
            Size {size}
          </Button>
        ))}
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        <Button data-testid="save-button" loading={saving} iconLeading={<SaveIcon />} onClick={save}>
          {saving ? 'Saving' : 'Save'}
        </Button>
        <Button hierarchy="secondary" disabled>
          Disabled
        </Button>
        <Button hierarchy="secondary" iconOnly aria-label="Add item">
          <PlusIcon />
        </Button>
      </div>
    </div>
  );
}

API

Parts: root (button), icon slot (leading icon or spinner), label, trailing icon.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"Height and padding: 32, 36, 40, 44 or 48px. Link hierarchies keep the text height.
hierarchy"primary" | "secondary" | "tertiary" | "link-color" | "link-gray""primary"Visual weight. Use one primary button per area.
destructivebooleanfalseUses the error palette and the error focus ring, for actions that delete data or cannot be undone. Works with primary, secondary, tertiary and link-color (the Figma Link); link-gray renders as link-color.
iconOnlybooleanfalseMakes a square button that shows only the icon passed as the label. Needs an aria-label. Not for link hierarchies.
loadingbooleanfalseShows the spinner in the icon slot, sets aria-busy and ignores clicks. The button keeps focus.
disabledbooleanfalseTurns the button off. Uses the native disabled attribute unless focusableWhenDisabled is set.
focusableWhenDisabledbooleanfalseKeeps a disabled button in the tab order with aria-disabled, so a tooltip can still explain why it is off.
type"button" | "submit" | "reset""button"Native button type.
EventDescription
clickFires on activation. Not fired while loading or disabled.
SlotDescription
defaultLabel text, or the icon when iconOnly is set.
iconLeadingIcon before the label, 20px. The spinner takes its place while loading. In React this is the iconLeading prop.
iconTrailingIcon after the label, 20px. In React this is the iconTrailing prop.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
BT1hoverrootbackground-color, color, border-colorDefault colors → Hover colors (for example bg-brand-solid to bg-brand-solid_hover)duration-sm (200)ease-outSwaps at once
BT2hover (link hierarchies)labeltext-decoration-colortransparent → fg-brand-secondary_alt or fg-quaternaryduration-sm (200)ease-outSwaps at once
BT3press (pointer down or Space held)rootscale1 → scale-pressduration-sm (200)ease-outStays still
BT4releaserootscalescale-press → 1duration-sm (200)ease-outStays still
BT5keyboard focusrootbox-shadowshadow-xs-skeuomorphic → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once
BT6loading -> true (no leading icon)icon slotwidth, margin-inline-end0, 0 → 20px, the size gapduration-xl (380)ease-outResizes at once
BT7loading -> trueleading icon -> spinneropacity, filter blur1, blur 0 → 0, blur-swap (the spinner does the reverse)duration-md (260)ease-outSwaps at once
BT8loading -> trueleading icon -> spinnerscale1 → scale-swap (the spinner does the reverse)duration-xl (380)ease-outSwaps at once
BT9while loadingspinner arcrotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
BT10loading -> falseicon slot and iconsreverse of BT6 to BT8spinner shown → leading icon or closed slotduration-xl (380)ease-outSwaps at once
BT11disabled -> truerootopacity1 → 0.5duration-sm (200)ease-outSwaps at once

Accessibility

  • Native button element with type button by default, so Enter and Space work without extra code.
  • While loading: aria-busy is true, clicks are ignored, and the button stays focusable so focus is not lost.
  • disabled uses the native attribute and leaves the tab order; focusableWhenDisabled uses aria-disabled instead and blocks clicks in code.
  • Icon-only buttons need an aria-label; icons and the spinner are aria-hidden.
  • Visible focus ring on keyboard focus only (focus-visible), built from the focus-ring token.
  • Link hierarchies at xs to md are 20px tall; a transparent hit area grows the target to 24px (WCAG 2.5.8).
  • Reduced motion: colors and icons swap at once, the press does not scale, and the spinner stops turning.