Lunia DS

Base components

Utility button

A small square icon button for toolbars, tables and cards that always names its action in a tooltip, eases its colors on hover and gives a small press.

Loading preview

A table row with three tertiary xs buttons ("Edit", "Copy link", "Delete") and a card header with two secondary sm buttons ("Download", "More actions"). Replay hovers "Copy link" so its tooltip shows.

Installation

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

$ npx @ganisigit/lunia-ds add utility-button

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { TooltipProvider } from '../tooltip/tooltip';
import { UtilityButton } from './utility-button';

const ICONS = {
  edit: 'M11.667 4.167 15.833 8.333M2.5 17.5l3.542-.417c.36-.042.54-.063.708-.118.15-.049.292-.118.422-.206.147-.1.275-.228.531-.484L17.5 6.479a2.062 2.062 0 0 0-2.917-2.917L4.786 13.36c-.256.256-.384.384-.484.531a1.67 1.67 0 0 0-.206.422c-.055.168-.076.348-.118.708L2.5 17.5Z',
  link: 'M8.333 10.833a4.167 4.167 0 0 0 6.284.45l2.5-2.5a4.167 4.167 0 0 0-5.892-5.891L9.792 4.317M11.667 9.167a4.167 4.167 0 0 0-6.284-.45l-2.5 2.5a4.167 4.167 0 0 0 5.892 5.891l1.425-1.425',
  trash:
    'M2.5 5h15M7.5 5V3.333h5V5M15.833 5l-.6 10.2a1.667 1.667 0 0 1-1.666 1.633H6.433A1.667 1.667 0 0 1 4.767 15.2L4.167 5M8.333 8.333v5M11.667 8.333v5',
  download: 'M10 2.5v10M5.833 8.333 10 12.5l4.167-4.167M3.333 15.833h13.334',
  more: 'M10 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM10 5a.833.833 0 1 0 0-1.667A.833.833 0 0 0 10 5ZM10 16.667A.833.833 0 1 0 10 15a.833.833 0 0 0 0 1.667Z',
} as const;

function Icon({ name, stroke }: { name: keyof typeof ICONS; stroke: number }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d={ICONS[name]} strokeWidth={stroke} vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

const ROW = [
  { label: 'Edit', icon: 'edit' },
  { label: 'Copy link', icon: 'link' },
  { label: 'Delete', icon: 'trash' },
] as const;

export default function UtilityButtonDemo({ replay = 0 }: { replay?: number }) {
  // Replay holds the "Copy link" tooltip open for a moment; null leaves it to the pointer and keyboard.
  const [copyOpen, setCopyOpen] = React.useState<boolean | undefined>(undefined);

  React.useEffect(() => {
    if (replay === 0) return;
    setCopyOpen(true);
    const timer = window.setTimeout(() => setCopyOpen(undefined), 1600);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    <TooltipProvider>
      <div className="flex w-full max-w-md flex-col gap-6">
        <div className="overflow-hidden rounded-xl border border-secondary bg-primary shadow-xs">
          <div className="flex items-center justify-between gap-3 border-b border-secondary px-4 py-3">
            <p className="text-md font-semibold text-primary">Monthly report</p>
            <div className="flex items-center gap-2">
              <UtilityButton label="Download">
                <Icon name="download" stroke={1.67} />
              </UtilityButton>
              <UtilityButton label="More actions">
                <Icon name="more" stroke={1.67} />
              </UtilityButton>
            </div>
          </div>
          <div className="flex items-center justify-between gap-3 px-4 py-3">
            <div className="flex flex-col">
              <span className="text-sm font-medium text-primary">Q3 summary.pdf</span>
              <span className="text-sm text-tertiary">2.4 MB</span>
            </div>
            <div className="flex items-center gap-0.5">
              {ROW.map((item) => (
                <UtilityButton
                  key={item.label}
                  label={item.label}
                  size="xs"
                  hierarchy="tertiary"
                  tooltipOpen={item.label === 'Copy link' ? copyOpen : undefined}
                >
                  <Icon name={item.icon} stroke={1.5} />
                </UtilityButton>
              ))}
            </div>
          </div>
        </div>
      </div>
    </TooltipProvider>
  );
}

API

Parts: root (button), icon, tooltip (from tooltip).

PropTypeDefaultDescription
labelstringundefinedRequired. Names the action: it is the aria-label and the tooltip text, for example "Copy link".
size"xs" | "sm""sm"28px with a 16px icon, or 32px with a 20px icon.
hierarchy"secondary" | "tertiary""secondary"secondary: white with a border and a small shadow. tertiary: no fill until hover.
tooltipSide"top" | "bottom" | "left" | "right""top"Side of the tooltip.
tooltipOpenbooleanundefinedControls the tooltip from outside, for example to show it in a demo. Leave it unset for the normal hover and focus behavior.
disabledbooleanfalseTurns the button off (50% opacity). It stays focusable with aria-disabled so the tooltip can still name it.
type"button" | "submit" | "reset""button"Native button type.
EventDescription
clickFires on activation. Not fired while disabled.
SlotDescription
defaultThe icon (16px at xs, 20px at sm).

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
UB1hoverroot, iconbackground-color, icon colorbg-primary or transparent, fg-tertiary → bg-primary_hover, fg-tertiary_hoverduration-sm (200)ease-outSwaps at once
UB2press (pointer down or Space held)rootscale1 → scale-pressduration-sm (200)ease-outStays still
UB3releaserootscalescale-press → 1duration-sm (200)ease-outStays still
UB4keyboard focusrootbox-shadowshadow-xs-skeuomorphic or none → focus ring (focus-ring-shadow-xs-skeuomorphic for secondary, focus-ring for tertiary)duration-2xs (120)ease-outShows at once
UB5disabled -> truerootopacity1 → 0.5duration-sm (200)ease-outSwaps at once

Accessibility

  • Native button with aria-label set to label; the icon is aria-hidden. label is required, so an icon-only button always has a name.
  • The tooltip shows on hover and on keyboard focus, after the tooltip delay (400ms), and repeats the label; it is not the only place the name lives.
  • Disabled uses aria-disabled and blocks clicks in code, so keyboard users can still reach the button and read why it is off.
  • Both sizes are 28px or larger, so the target meets the 24 x 24px minimum (WCAG 2.5.8).
  • Reduced motion: colors swap at once and the button does not scale; the tooltip follows its own reduced rules.