Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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

PropTypeDefaultDescription
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.