Square button with a required label, warm tooltips and a sprung squash.
01Preview
Q3 planning
Synced 4m ago
q3-forecast.xlsx
2.4 MB · Maya Chen
board-deck-final-v7-with-appendix.pdf
11.8 MB · Theo Park
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { IconButton, IconButtonProvider } from "@/components/ui/icon-button";
<IconButtonProvider>
<IconButton label="Copy link" shortcut="⌘ L" onClick={copyLink}>
<Link />
</IconButton>
<IconButton label="Sync now" loading={syncing} onClick={sync}>
<Refresh />
</IconButton>
</IconButtonProvider>04Source
"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/cn";
import { spring, swap } from "@/lib/motion";
export type IconButtonSize = "sm" | "md" | "lg";
export type IconButtonVariant = "ghost" | "secondary";
export type TooltipSide = "top" | "bottom" | "left" | "right";
const sizes: Record<IconButtonSize, string> = {
sm: "size-7 rounded-md [&_svg]:size-3.5",
md: "size-8 rounded-lg [&_svg]:size-4",
lg: "size-9 rounded-lg [&_svg]:size-4",
};
// Inside a ButtonGroup the button becomes a flat segment: the group draws the
// border, shadow, hairlines and the gliding hover wash, and only the icon squashes.
const segment =
"in-data-[slot=button-group]:rounded-none in-data-[slot=button-group]:border-0 in-data-[slot=button-group]:bg-transparent in-data-[slot=button-group]:shadow-none in-data-[slot=button-group]:hover:bg-transparent in-data-[slot=button-group]:data-popup-open:bg-transparent in-data-[slot=button-group]:active:scale-100 in-data-[slot=button-group]:focus-visible:-outline-offset-3 in-data-[slot=button-group]:focus-visible:rounded-[5px]" + " in-data-[slot=button-group]:active:bg-transparent in-data-[slot=button-group]:transform-none!";
const variants: Record<IconButtonVariant, string> = {
ghost: "text-fg-2 hover:bg-hover hover:text-fg active:bg-fg/[0.08] data-popup-open:bg-hover data-popup-open:text-fg " + segment,
secondary:
"border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover hover:text-fg active:bg-fg/[0.08] data-popup-open:border-fg-4 data-popup-open:bg-hover data-popup-open:text-fg " + segment,
};
/**
* Wrap a toolbar in this so its tooltips share one warm-up: the first waits
* ~500ms, the next ones open instantly while the pointer stays in the group.
*/
export function IconButtonProvider({ delay = 500, children }: { delay?: number; children: React.ReactNode }) {
return (
<Tooltip.Provider delay={delay} closeDelay={0} timeout={400}>
{children}
</Tooltip.Provider>
);
}
/** A shortcut as keycaps, split on spaces: "⌘ B", "Shift D". */
export function Keys({ keys, className }: { keys: string; className?: string }) {
return (
<kbd className={cn("inline-flex items-center gap-0.5 font-sans", className)}>
{keys.split(" ").map((k, i) => (
<span
key={i}
className="grid h-4 min-w-4 place-items-center rounded-[4px] border border-line-2 px-1 font-mono text-[10px] leading-none text-fg-3"
>
{k}
</span>
))}
</kbd>
);
}
export type ActionTooltipProps = {
/** What the control does. */
content: React.ReactNode;
shortcut?: string;
side?: TooltipSide;
/** Milliseconds before opening. Defaults to the provider's, or 600 without one. */
delay?: number;
disabled?: boolean;
/** The trigger. It receives the tooltip's props and ref. */
children: React.ReactElement;
};
/**
* The small tooltip every icon-only control carries: grows 4% from the side it's
* on, never covers its trigger, and is skipped on touch screens.
*/
export function ActionTooltip({ content, shortcut, side = "top", delay, disabled, children }: ActionTooltipProps) {
return (
<Tooltip.Root disabled={disabled}>
<Tooltip.Trigger delay={delay} render={children} />
<Tooltip.Portal>
<Tooltip.Positioner side={side} sideOffset={6} collisionPadding={8} className="z-(--z-tooltip)">
<Tooltip.Popup
className={cn(
"flex min-h-6 max-w-64 items-center gap-2 text-balance rounded-md border border-line-2 bg-raised py-1 pl-2 text-[12px] leading-4 text-fg shadow-pop",
shortcut ? "pr-1" : "pr-2",
"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo",
"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100",
"data-[side=top]:data-starting-style:translate-y-0.5 data-[side=bottom]:data-starting-style:-translate-y-0.5",
"data-[side=left]:data-starting-style:translate-x-0.5 data-[side=right]:data-starting-style:-translate-x-0.5",
// Moving between warm siblings, or opening from the keyboard, is instant.
"data-instant:duration-0",
)}
>
{content}
{shortcut && <Keys keys={shortcut} />}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
);
}
export type IconButtonProps = Omit<React.ComponentProps<"button">, "children" | "aria-label"> & {
/** What the button does ("Copy link", not "Clipboard"). Its accessible name and its tooltip. */
label: string;
/** The icon. */
children: React.ReactNode;
size?: IconButtonSize;
variant?: IconButtonVariant;
/**
* The tooltip on hover and keyboard focus. `true` shows the label; pass text to
* say less than the label when the row already names the object ("Download"
* for a button labeled "Download q3-forecast.xlsx"); `false` for none.
*/
tooltip?: boolean | React.ReactNode;
/** A shortcut shown in the tooltip, as space-separated keys: "⌘ D". */
shortcut?: string;
tooltipSide?: TooltipSide;
/** Swaps the icon for a spinner and ignores presses, keeping focus. */
loading?: boolean;
};
export function IconButton({
label,
children,
size = "md",
variant = "ghost",
tooltip = true,
shortcut,
tooltipSide = "top",
loading = false,
disabled,
className,
onClick,
ref,
...rest
}: IconButtonProps) {
const reduce = useReducedMotion();
const button = (
<motion.button
ref={ref}
type="button"
aria-label={label}
aria-busy={loading || undefined}
aria-disabled={loading || undefined}
disabled={disabled}
data-size={size}
data-variant={variant}
data-busy={loading ? "" : undefined}
// The squash: a quick press to 92%, then a spring back with a pixel of overshoot.
// Always an object: Motion adds tabindex for tap gestures, and server and client must agree.
whileTap={loading || disabled || reduce ? {} : { scale: 0.92, transition: { duration: 0.08 } }}
transition={spring.bouncy}
onClick={(e) => {
if (loading) return e.preventDefault();
onClick?.(e);
}}
className={cn(
"group/icon relative inline-grid shrink-0 select-none place-items-center outline-none",
"touch-manipulation [-webkit-tap-highlight-color:transparent]",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,color] duration-150 ease-out-quart",
"disabled:pointer-events-none disabled:opacity-50 data-busy:cursor-progress",
// 44px to a finger, whatever it draws at.
"pointer-coarse:after:absolute pointer-coarse:after:left-1/2 pointer-coarse:after:top-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2",
sizes[size],
variants[variant],
className,
)}
{...(rest as React.ComponentProps<typeof motion.button>)}
>
<AnimatePresence initial={false}>
<motion.span
key={loading ? "busy" : "icon"}
aria-hidden
className="col-start-1 row-start-1 grid place-items-center transition-[scale] duration-100 ease-out-quart in-data-[slot=button-group]:group-active/icon:scale-[0.86]"
initial={reduce ? { opacity: 0 } : swap.initial}
// Same resting style either way, so server and client markup agree.
animate={swap.animate}
exit={reduce ? { opacity: 0 } : swap.exit}
transition={reduce ? { duration: 0.12 } : spring.pop}
>
{loading ? <Spinner /> : children}
</motion.span>
</AnimatePresence>
</motion.button>
);
if (tooltip === false || tooltip == null) return button;
return (
<ActionTooltip content={tooltip === true ? label : tooltip} shortcut={shortcut} side={tooltipSide} disabled={disabled}>
{button}
</ActionTooltip>
);
}
function Spinner() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden className="animate-[spin_0.7s_linear_infinite]">
<circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeOpacity="0.22" strokeWidth="1.5" />
<path d="M8 2.25A5.75 5.75 0 0 1 13.75 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}05Props
IconButton
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | What it does, as a verb ("Copy link"). The accessible name, and the tooltip by default. |
| children* | ReactNode | — | The icon. Sized to 14px in sm and 16px otherwise. |
| size | "sm" | "md" | "lg" | "md" | 28, 32 or 36px square. Every size takes a 44px tap on touch screens. |
| variant | "ghost" | "secondary" | "ghost" | No chrome until hover, or a bordered raised surface. |
| tooltip | boolean | ReactNode | true | true shows the label; text shows something shorter than the label; false hides it. |
| shortcut | string | — | Keys shown as keycaps in the tooltip, space-separated: "⌘ ⇧ M". |
| tooltipSide | "top" | "bottom" | "left" | "right" | "top" | Preferred side; flips when there isn't room. |
| loading | boolean | false | Swaps the icon for a spinner, ignores presses and keeps focus. |
IconButtonProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| delay | number | 500 | Wait before the first tooltip in the group; siblings then open instantly for 400ms after one closes. |
ActionTooltip
| Prop | Type | Default | Description |
|---|---|---|---|
| content* | ReactNode | — | The tooltip text. Wrap any single trigger element in it. |
| shortcut | string | — | Keycaps after the text. |
| side | TooltipSide | "top" | Preferred side. |
| children* | ReactElement | — | The trigger; it receives the tooltip's props and ref. |
06Notes
Behavior
- The label is required by the type, so an unnamed icon button can't be written.
- In a list, give each button a specific label ("Delete q3-forecast.xlsx") and a short tooltip ("Delete"): screen readers get the object, sighted users aren't read the row back.
- Long tooltips wrap at 256px instead of running off the screen, and flip sides near an edge.
- Tooltips never show on touch, so nothing essential lives in one; the 44px hit area extends only on coarse pointers so neighboring buttons in a desktop toolbar don't steal each other's clicks.
- Inside a ButtonGroup it flattens into a segment and only the icon squashes, so the group's border never tears.
Motion
- Press squashes to 0.92 in 80ms and springs back (stiffness 420, damping 20) with about a third of a pixel of overshoot, so it feels pushed rather than blinked.
- Tooltips grow from 0.96 and 2px toward their trigger in 150ms on the expo ease-out and leave in 100ms; moving between warm siblings or opening from the keyboard is instant.
- The loading swap scales the icon to 0.6 with a 3px blur while the spinner pops in. Reduced motion drops the squash and scale for a 120ms crossfade.
Accessibility
- A native button named by aria-label; the icon is aria-hidden.
- The tooltip opens on keyboard focus as well as hover, and closes on Escape or press.
- Loading sets aria-busy and aria-disabled but keeps the button focusable.