Components

Getting started

Components121

More

Compact icon toolbar where the hovered or focused action grows to reveal its label while neighbours slide aside, with in-place confirmation.

Used forDocument header toolbarsCanvas & card hover barsNote editors & markdown headers

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/expanding-button-group.json

Where it’s used

  • Document header toolbars

    Quick actions (Undo, Redo, Archive, History, Delete) in compact spaces.

  • Canvas & card hover bars

    Contextual toolbars that expand details on hover without cluttering the screen.

  • Note editors & markdown headers

    Publish, copy, and formatting actions with immediate in-place feedback.

What it handles for you

Toolbars with full text labels take excessive horizontal space, while icon-only toolbars leave users guessing what each glyph does. This component gives the density of icon buttons with the clarity of full text on demand.

Instead of wiring upcramped text toolbarscryptic icon-only bars with laggy tooltipsmodal confirmation popups
  1. Smooth grid expansion

    Labels expand horizontally via grid-template-columns transitions without stuttering layout shifts.

  2. Sliding neighbours

    Neighbouring buttons slide aside with rational easing curves as the active item grows.

  3. In-place confirmation

    Selected actions morph their label and display an affirmative green checkmark for 1.6s before reverting.

  4. Semantic tones

    Supports default neutral, primary, and danger tone pills for critical actions like Delete.

Overview

An interactive toolbar designed for high-density action surfaces. Resting as a minimal row of icon buttons inside a calm hairline capsule, pointing at or focusing any icon smoothly expands its text label using CSS grid layout transitions, neighbours slide aside with organic easing, and actions can confirm in place upon execution.

States

resting
Compact hairline capsule showing icon buttons.
expanded
Hovered or focused item expands its pill background and reveals text label.
confirmed
In-place checkmark and confirmLabel feedback on execution.

Usage

usage.tsx
import { ExpandingButtonGroup } from "@/components/ui/expanding-button-group";import { Download, Link2, Trash2 } from "lucide-react";<ExpandingButtonGroup  items={[    { id: "download", label: "Download", icon: <Download size={14} />, confirmLabel: "Downloaded!" },    { id: "link", label: "Copy link", icon: <Link2 size={14} />, confirmLabel: "Copied!" },    { id: "delete", label: "Delete", icon: <Trash2 size={14} />, tone: "danger" },  ]}/>

Works with

Props

PropTypeDescription
itemsExpandingButtonItem[]Array of actions with id, label, icon, tone, and confirmLabel.
defaultExpandedIdstring | nulldefault nullItem id to show expanded by default.
size"sm" | "md"default "md"Height and icon scale.
labelstringdefault "Action toolbar"Accessible toolbar label for screen readers.

Accessibility and motion

  • Pure CSS grid animation ensures zero layout reflow overhead.
  • Full arrow-key navigation allows keyboard users to cycle through tools effortlessly.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/expanding-button-group.tsx
"use client";import * as React from "react";import {  EASE_OUT,  INTERACTION_FOCUS,  useInteractionReducedMotion,} from "./ui-interaction-motion";export type ExpandingItemTone = "default" | "primary" | "danger";export interface ExpandingButtonItem {  id: string;  label: string;  icon: React.ReactNode;  tone?: ExpandingItemTone;  confirmLabel?: string;  onClick?: () => void;  disabled?: boolean;}export interface ExpandingButtonGroupProps  extends React.HTMLAttributes<HTMLDivElement> {  items: ExpandingButtonItem[];  /** Controlled active/expanded item id. If not provided, hover/focus controls expansion. */  expandedId?: string | null;  /** Initial item to be expanded. */  defaultExpandedId?: string | null;  /** Visual density. Defaults to md. */  size?: "sm" | "md";  /** Aria label for the toolbar group. */  label?: string;}const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";function InlineCheck({ size }: { size: number }) {  return (    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0">      <path        d="M3.5 8.5 6.5 11.5 12.5 4.5"        stroke="currentColor"        strokeWidth="2"        strokeLinecap="round"        strokeLinejoin="round"      />    </svg>  );}const toneStyles = {  default: {    idle: "text-muted-foreground hover:text-foreground hover:bg-muted/70",    expanded: "bg-muted text-foreground font-semibold",    activePress: "scale-[0.96]",  },  primary: {    idle: "text-muted-foreground hover:text-foreground hover:bg-muted/70",    expanded: "bg-primary text-primary-foreground font-semibold shadow-[0_1px_2px_rgba(48,93,222,0.18)]",    activePress: "scale-[0.96]",  },  danger: {    idle: "text-muted-foreground hover:text-red-600 hover:bg-red-500/10 dark:hover:text-red-400",    expanded: "bg-red-500/10 text-red-600 dark:text-red-400 font-semibold shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_25%,transparent)]",    activePress: "scale-[0.96]",  },};export function ExpandingButtonGroup({  items,  expandedId: controlledExpandedId,  defaultExpandedId = null,  size = "md",  label = "Action toolbar",  className = "",  style,  ...props}: ExpandingButtonGroupProps) {  const reduced = useInteractionReducedMotion();  const [internalExpandedId, setInternalExpandedId] = React.useState<string | null>(    defaultExpandedId  );  const [confirmedId, setConfirmedId] = React.useState<string | null>(null);  const [pressedId, setPressedId] = React.useState<string | null>(null);  const groupRef = React.useRef<HTMLDivElement | null>(null);  const confirmTimer = React.useRef<number>(0);  const activeId = controlledExpandedId !== undefined ? controlledExpandedId : internalExpandedId;  // Clean timer on unmount  React.useEffect(() => () => window.clearTimeout(confirmTimer.current), []);  const handlePointerEnter = (id: string, disabled?: boolean) => {    if (disabled) return;    setInternalExpandedId(id);  };  const handlePointerLeave = () => {    if (controlledExpandedId === undefined) {      setInternalExpandedId(null);    }  };  const handleFocus = (id: string, disabled?: boolean) => {    if (disabled) return;    setInternalExpandedId(id);  };  const handleBlur = (e: React.FocusEvent) => {    if (groupRef.current?.contains(e.relatedTarget as Node)) return;    if (controlledExpandedId === undefined) {      setInternalExpandedId(null);    }  };  const handleClick = (item: ExpandingButtonItem) => {    if (item.disabled) return;    if (item.confirmLabel) {      setConfirmedId(item.id);      window.clearTimeout(confirmTimer.current);      confirmTimer.current = window.setTimeout(() => {        setConfirmedId(null);      }, 1600);    }    item.onClick?.();  };  // Keyboard navigation across items  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    const buttons = Array.from(      groupRef.current?.querySelectorAll<HTMLButtonElement>("button[data-expanding-item]") ?? []    );    const currentIndex = buttons.findIndex((btn) => btn === document.activeElement);    if (currentIndex < 0) return;    let nextIndex = -1;    if (e.key === "ArrowRight") {      nextIndex = currentIndex < buttons.length - 1 ? currentIndex + 1 : 0;    } else if (e.key === "ArrowLeft") {      nextIndex = currentIndex > 0 ? currentIndex - 1 : buttons.length - 1;    } else if (e.key === "Home") {      nextIndex = 0;    } else if (e.key === "End") {      nextIndex = buttons.length - 1;    }    if (nextIndex >= 0) {      e.preventDefault();      const targetBtn = buttons[nextIndex];      targetBtn?.focus();      const nextItem = items[nextIndex];      if (nextItem && !nextItem.disabled) {        setInternalExpandedId(nextItem.id);      }    }  };  const heightClass = size === "sm" ? "h-7" : "h-8.5";  const iconBoxSize = size === "sm" ? "size-7" : "size-8.5";  const iconSize = size === "sm" ? 13 : 14;  return (    <div      ref={groupRef}      role="toolbar"      aria-label={label}      onKeyDown={handleKeyDown}      onPointerLeave={handlePointerLeave}      onBlur={handleBlur}      style={style}      className={`relative inline-flex items-center rounded-full p-1 gap-1 select-none isolate bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] max-w-full overflow-x-auto ${className}`}      {...props}    >      {items.map((item) => {        const isExpanded = activeId === item.id;        const isConfirmed = confirmedId === item.id;        const isPressed = pressedId === item.id;        const tone = item.tone ?? "default";        const toneStyle = toneStyles[tone];        const displayLabel = isConfirmed          ? item.confirmLabel ?? "Done!"          : item.label;        return (          <button            key={item.id}            type="button"            data-expanding-item=""            disabled={item.disabled}            aria-label={item.label}            onPointerEnter={() => handlePointerEnter(item.id, item.disabled)}            onFocus={() => handleFocus(item.id, item.disabled)}            onClick={() => handleClick(item)}            onPointerDown={() => !item.disabled && setPressedId(item.id)}            onPointerUp={() => setPressedId(null)}            style={{              transform: isPressed ? "scale(0.96)" : undefined,              transition: reduced                ? "none"                : `transform 140ms ${EASE}, background-color 160ms ${EASE}, color 160ms ${EASE}`,            }}            className={`relative flex items-center justify-center rounded-full cursor-pointer transition-colors duration-160 select-none disabled:cursor-not-allowed disabled:opacity-40 ${heightClass} ${              isExpanded ? "px-2.5" : `${iconBoxSize} px-0`            } ${isExpanded ? toneStyle.expanded : toneStyle.idle} ${INTERACTION_FOCUS}`}          >            {/* Leading Icon Slot */}            <span              className={`relative inline-flex shrink-0 items-center justify-center transition-transform duration-160 ${                isConfirmed ? "text-emerald-500 scale-105" : ""              }`}              style={{                width: iconSize,                height: iconSize,              }}            >              {isConfirmed ? <InlineCheck size={iconSize} /> : item.icon}            </span>            {/* Expanding Text Label with CSS Grid Smooth Layout Transition */}            <span              className="grid overflow-hidden whitespace-nowrap text-[12.5px] font-medium transition-all"              style={{                gridTemplateColumns: isExpanded ? "1fr" : "0fr",                opacity: isExpanded ? 1 : 0,                transition: reduced                  ? "none"                  : `grid-template-columns 240ms ${EASE}, opacity 180ms ${EASE}`,              }}            >              <span className="min-w-0 overflow-hidden pl-1.5 pr-0.5 font-medium truncate">                {displayLabel}              </span>            </span>          </button>        );      })}    </div>  );}export default ExpandingButtonGroup;

Registry item: /r/expanding-button-group.json