Navigationv0.1.0
Expandable Action Bar
Icon buttons expand into labeled controls.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/action-bar.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/action-bar/action-bar.tsx"use client"; import { useId, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface ActionBarItem { /** Unique id. */ id: string; /** Label revealed on hover and focus; always the accessible name. */ label: string; /** Icon shown at all times. */ icon: ReactNode; /** Runs when the action is chosen. */ onClick?: () => void; /** Style the action as destructive. */ destructive?: boolean; /** Prevents choosing the action. */ disabled?: boolean; } export interface ActionBarProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Actions, in order. */ items: ActionBarItem[]; /** Bar color. @default "light" */ tone?: "light" | "dark"; /** Accessible name of the toolbar. @default "Actions" */ "aria-label"?: string; } const tones = { light: { bar: "border border-border bg-background shadow-md", idle: "text-muted-foreground", open: "bg-accent text-foreground", danger: "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", }, dark: { bar: "bg-foreground shadow-lg", idle: "text-background/65", open: "bg-background/15 text-background", danger: "bg-destructive/25 text-[color-mix(in_oklab,var(--destructive)_45%,white)]", }, } as const; const LAYOUT = { type: "spring", stiffness: 480, damping: 36 } as const; export function ActionBar({ items, tone = "light", className, "aria-label": ariaLabel = "Actions", ...props }: ActionBarProps) { const [hovered, setHovered] = useState<string | null>(null); const [focused, setFocused] = useState<string | null>(null); const [tabStop, setTabStop] = useState(0); const buttons = useRef<(HTMLButtonElement | null)[]>([]); const reduceMotion = useReducedMotion(); const groupId = useId(); const t = tones[tone]; const layout = reduceMotion ? { duration: 0 } : LAYOUT; // Focus wins over hover, so keyboard users always see what they're on. const expanded = focused ?? hovered; function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0); const position = enabled.indexOf(tabStop); const moves: Record<string, number | undefined> = { ArrowRight: enabled[(position + 1) % enabled.length], ArrowLeft: enabled[(position - 1 + enabled.length) % enabled.length], Home: enabled[0], End: enabled[enabled.length - 1], }; if (!(event.key in moves)) return; event.preventDefault(); const next = moves[event.key]; if (next === undefined) return; setTabStop(next); buttons.current[next]?.focus(); } return ( <LayoutGroup id={groupId}> <motion.div layout transition={layout} role="toolbar" aria-label={ariaLabel} onKeyDown={onKeyDown} onPointerLeave={() => setHovered(null)} style={{ borderRadius: 999 }} className={cn("inline-flex items-center gap-1 p-1.5", t.bar, className)} {...props} > {items.map((item, index) => { const open = expanded === item.id && !item.disabled; return ( <motion.button key={item.id} ref={(node) => { buttons.current[index] = node; }} layout transition={layout} type="button" aria-label={item.label} aria-disabled={item.disabled || undefined} tabIndex={index === tabStop ? 0 : -1} onClick={() => !item.disabled && item.onClick?.()} onPointerEnter={() => setHovered(item.id)} onFocus={() => { setFocused(item.id); setTabStop(index); }} onBlur={() => setFocused(null)} style={{ borderRadius: 999 }} className={cn( "relative flex h-10 shrink-0 cursor-pointer items-center gap-2 px-3 text-sm font-medium whitespace-nowrap outline-none", "transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[18px] [&_svg]:shrink-0", item.disabled ? cn(t.idle, "cursor-not-allowed opacity-40") : open ? (item.destructive ? t.danger : t.open) : t.idle, )} > <motion.span layout="position" transition={layout} className="flex" aria-hidden="true"> {item.icon} </motion.span> <AnimatePresence mode="popLayout" initial={false}> {open ? ( <motion.span key="label" layout="position" aria-hidden="true" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -4, filter: "blur(3px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.quick, ease: ease.out, layout }} > {item.label} </motion.span> ) : null} </AnimatePresence> </motion.button> ); })} </motion.div> </LayoutGroup> ); }
Usage
import { ActionBar } from "@/components/ui/action-bar/action-bar";
export function RowActions() {
return (
<ActionBar
aria-label="File actions"
items={[
{ id: "edit", label: "Edit", icon: <PencilIcon />, onClick: edit },
{ id: "delete", label: "Delete", icon: <TrashIcon />, destructive: true, onClick: remove },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | ActionBarItem[] | – | Actions, in order. |
tone | "light" | "dark" | "light" | Bar color. |
"aria-label" | string | "Actions" | Accessible name of the toolbar. |
Also accepts every native <div> attribute, including ref and className.
- Buttons and the bar resize with Motion layout animations (transforms with distortion correction), never width.
- Use the dark tone for floating selection bars over content.
Accessibility
- A toolbar with one tab stop; arrow keys, Home and End move between actions and skip disabled ones.
- Every action keeps its label as the accessible name (aria-label), whether or not the label is showing.
- Keyboard focus expands the focused action and takes priority over hover.
- With reduced motion enabled, actions resize instantly and labels fade in place.