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.
Live preview
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-buttonUsage
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).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | Required. 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. |
| tooltipOpen | boolean | undefined | Controls the tooltip from outside, for example to show it in a demo. Leave it unset for the normal hover and focus behavior. |
| disabled | boolean | false | Turns 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. |
| Event | Description |
|---|---|
| click | Fires on activation. Not fired while disabled. |
| Slot | Description |
|---|---|
| default | The 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| UB1 | hover | root, icon | background-color, icon color | bg-primary or transparent, fg-tertiary → bg-primary_hover, fg-tertiary_hover | duration-sm (200)ease-out | Swaps at once |
| UB2 | press (pointer down or Space held) | root | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| UB3 | release | root | scale | scale-press → 1 | duration-sm (200)ease-out | Stays still |
| UB4 | keyboard focus | root | box-shadow | shadow-xs-skeuomorphic or none → focus ring (focus-ring-shadow-xs-skeuomorphic for secondary, focus-ring for tertiary) | duration-2xs (120)ease-out | Shows at once |
| UB5 | disabled -> true | root | opacity | 1 → 0.5 | duration-sm (200)ease-out | Swaps 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.