Components

Getting started

Components117

More

Connected actions joined into one surface: hairline dividers, a gliding hover highlight, and responsive auto-collapsing labels.

Used forEditor toolbarsSegmented view switchesResponsive header controls

Install with the shadcn CLI

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

Where it’s used

  • Editor toolbars

    Text alignment, formatting, and view toggle controls.

  • Segmented view switches

    Switching between Grid, List, and Table layouts.

  • Responsive header controls

    Actions that collapse gracefully on mobile devices.

What it handles for you

Multiple buttons side by side usually create cramped margins and multiple clashing borders. This component joins actions into one continuous hairline surface with smooth kinetic focus gliding.

Instead of wiring updisconnected buttonsseparate toolbar itemsclashing marginsmanual mobile icon swapping
  1. Gliding highlight

    A soft pill highlight glides seamlessly between segments on hover or arrow-key focus.

  2. Quiet dividers

    Internal hairline dividers fade out beside the active segment so the highlight reads as a clean shape.

  3. Auto-collapsing labels

    When container space is constrained, text labels collapse into icons automatically while preserving full accessibility and titles.

  4. Horizontal & vertical

    Supports both horizontal toolbars and vertical sidebar action bars.

Overview

Related actions fused into a single hairline capsule with internal dividing hairlines. A soft highlight glides between segments under cursor hover or keyboard navigation, dividers quietly step aside next to active items, and labels automatically collapse to icons on narrow screens.

States

idle
Resting capsule with hairline dividers between segments.
hovered
Gliding highlight pill positioned under active segment; neighboring dividers fade.
focused
Keyboard focus ring and highlight navigating with arrow keys.
compact
Collapsed labels showing icons on narrow containers.

Usage

usage.tsx
import { ButtonGroup } from "@/components/ui/button-group";import { Grid, List, Columns } from "lucide-react";<ButtonGroup  items={[    { id: "grid", label: "Grid", icon: <Grid size={14} />, active: true },    { id: "list", label: "List", icon: <List size={14} /> },    { id: "columns", label: "Columns", icon: <Columns size={14} /> },  ]}/>

Works with

Props

PropTypeDescription
itemsButtonGroupItem[]Array of action items in the group.
variant"outline" | "solid"default "outline"Outline with hairline border or solid dark fill.
size"sm" | "md"default "md"Control height and typography.
orientation"horizontal" | "vertical"default "horizontal"Layout direction.
collapseLabelsbooleandefault trueAutomatically collapse labels to icons when space overflows.

Accessibility and motion

  • No external animation libraries: uses CSS transitions and native layout geometry.
  • Full keyboard accessibility: navigate with Arrow keys, Home, and End.

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/button-group.tsx
"use client";import * as React from "react";import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS } from "./ui-interaction-motion";export type ButtonGroupVariant = "outline" | "solid";export type ButtonGroupSize = "sm" | "md";export type ButtonGroupOrientation = "horizontal" | "vertical";export interface ButtonGroupItem {  id: string;  label: string;  icon?: React.ReactNode;  iconOnly?: boolean;  content?: React.ReactNode;  onClick?: () => void;  href?: string;  disabled?: boolean;  active?: boolean;}export interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {  items: ButtonGroupItem[];  variant?: ButtonGroupVariant;  size?: ButtonGroupSize;  orientation?: ButtonGroupOrientation;  collapseLabels?: boolean;  label?: string;}export function ButtonGroup({  items,  variant = "outline",  size = "md",  orientation = "horizontal",  collapseLabels = true,  label = "Action group",  className = "",  style,  ...props}: ButtonGroupProps) {  const rootRef = React.useRef<HTMLDivElement | null>(null);  const [hoveredId, setHoveredId] = React.useState<string | null>(null);  const [focusedId, setFocusedId] = React.useState<string | null>(null);  const [isCompact, setIsCompact] = React.useState(false);  // Position of the gliding highlight  const [highlightRect, setHighlightRect] = React.useState<{    x: number;    y: number;    width: number;    height: number;    visible: boolean;  }>({ x: 0, y: 0, width: 0, height: 0, visible: false });  const activeId = hoveredId ?? focusedId;  const isVertical = orientation === "vertical";  // Measure active element to glide highlight  React.useLayoutEffect(() => {    if (!rootRef.current || !activeId) {      setHighlightRect((prev) => ({ ...prev, visible: false }));      return;    }    const activeEl = rootRef.current.querySelector<HTMLElement>(`[data-group-item="${activeId}"]`);    if (!activeEl) {      setHighlightRect((prev) => ({ ...prev, visible: false }));      return;    }    const rootRect = rootRef.current.getBoundingClientRect();    const itemRect = activeEl.getBoundingClientRect();    setHighlightRect({      x: itemRect.left - rootRect.left,      y: itemRect.top - rootRect.top,      width: itemRect.width,      height: itemRect.height,      visible: true,    });  }, [activeId, isCompact, size, items]);  // Responsive container observer: if parent width is tight, collapse text labels to icons  React.useEffect(() => {    const root = rootRef.current;    if (!root || !collapseLabels || isVertical) return;    const checkSpace = () => {      const parent = root.parentElement;      const available = parent ? parent.clientWidth : window.innerWidth;      if (available < 340 || (parent && root.scrollWidth > available - 16)) {        setIsCompact(true);      } else if (available >= 380) {        setIsCompact(false);      }    };    checkSpace();    const ro = new ResizeObserver(checkSpace);    if (root.parentElement) ro.observe(root.parentElement);    ro.observe(root);    window.addEventListener("resize", checkSpace);    return () => {      ro.disconnect();      window.removeEventListener("resize", checkSpace);    };  }, [collapseLabels, isVertical, items]);  // Keyboard navigation across segments  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    const list = Array.from(rootRef.current?.querySelectorAll<HTMLElement>("[data-group-item]") ?? []);    const currentIndex = list.findIndex((node) => node === document.activeElement);    if (currentIndex < 0) return;    let nextIndex = -1;    if (isVertical) {      if (e.key === "ArrowDown") nextIndex = currentIndex < list.length - 1 ? currentIndex + 1 : 0;      else if (e.key === "ArrowUp") nextIndex = currentIndex > 0 ? currentIndex - 1 : list.length - 1;    } else {      if (e.key === "ArrowRight") nextIndex = currentIndex < list.length - 1 ? currentIndex + 1 : 0;      else if (e.key === "ArrowLeft") nextIndex = currentIndex > 0 ? currentIndex - 1 : list.length - 1;    }    if (e.key === "Home") nextIndex = 0;    if (e.key === "End") nextIndex = list.length - 1;    if (nextIndex >= 0) {      e.preventDefault();      list[nextIndex]?.focus();      setFocusedId(items[nextIndex]?.id ?? null);    }  };  const heightClass = size === "sm" ? "h-7 text-[12px]" : "h-9 text-[13px]";  const paddingClass = size === "sm" ? "px-3" : "px-4";  const iconOnlyWidth = size === "sm" ? "w-7" : "w-9";  return (    <div      ref={rootRef}      role="group"      aria-label={label}      onKeyDown={handleKeyDown}      style={style}      className={`relative inline-flex max-w-full items-stretch rounded-full border select-none overflow-hidden isolate shrink-0 ${        isVertical ? "flex-col rounded-[14px]" : "flex-row rounded-full"      } ${        variant === "solid"          ? "bg-primary text-primary-foreground border-transparent shadow-[0_1px_2px_rgba(48,93,222,0.18)]"          : "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] border-transparent"      } ${className}`}      {...props}    >      {/* Gliding Highlight Pill */}      <span        aria-hidden="true"        className={`absolute pointer-events-none z-0 rounded-full transition-all duration-200 motion-reduce:transition-none ${          variant === "solid" ? "bg-primary-foreground/20" : "bg-muted"        }`}        style={{          transform: `translate3d(${highlightRect.x}px, ${highlightRect.y}px, 0)`,          width: highlightRect.width ? `${highlightRect.width}px` : 0,          height: highlightRect.height ? `${highlightRect.height}px` : 0,          opacity: highlightRect.visible ? 1 : 0,          transition: `transform 200ms ${EASE_SETTLE}, width 200ms ${EASE_SETTLE}, height 200ms ${EASE_SETTLE}, opacity 160ms ${EASE_OUT}`,        }}      />      {items.map((item, index) => {        const isHovered = hoveredId === item.id;        const isNeighborHovered =          (index > 0 && items[index - 1]?.id === hoveredId) ||          (index < items.length - 1 && items[index + 1]?.id === hoveredId);        const isActive = !!item.active;        const isNeighborActive =          (index > 0 && !!items[index - 1]?.active) ||          (index < items.length - 1 && !!items[index + 1]?.active);        // Hide divider if either segment is hovered or active        const hideDivider = isHovered || isNeighborHovered || isActive || isNeighborActive;        const showIconOnly = item.iconOnly || (isCompact && !!item.icon);        const content = (          <span className="relative z-10 inline-flex items-center justify-center gap-1.5 font-medium">            {item.icon && <span className="inline-flex shrink-0 items-center justify-center">{item.icon}</span>}            {item.content !== undefined ? (              item.content            ) : !showIconOnly ? (              <span className="truncate">{item.label}</span>            ) : null}          </span>        );        const activeStyles = isActive          ? variant === "solid"            ? "bg-primary-foreground/25 text-primary-foreground font-semibold"            : "bg-muted text-foreground font-semibold"          : "";        const itemClasses = `relative z-10 inline-flex items-center justify-center cursor-pointer font-medium rounded-full transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none ${heightClass} ${          showIconOnly ? `${iconOnlyWidth} px-0 justify-center` : paddingClass        } ${activeStyles} ${INTERACTION_FOCUS}`;        const handlePointerEnter = () => {          if (!item.disabled) setHoveredId(item.id);        };        const handlePointerLeave = () => {          setHoveredId((current) => (current === item.id ? null : current));        };        const handleFocus = () => {          if (!item.disabled) setFocusedId(item.id);        };        const handleBlur = () => {          setFocusedId((current) => (current === item.id ? null : current));        };        return (          <React.Fragment key={item.id}>            {/* Hairline Divider between segments */}            {index > 0 && (              <span                aria-hidden="true"                className={`transition-opacity duration-150 shrink-0 ${                  isVertical                    ? "h-px w-full"                    : "w-px h-[50%] self-center"                } ${                  variant === "solid" ? "bg-primary-foreground/25" : "bg-border"                } ${hideDivider ? "opacity-0" : "opacity-100"}`}              />            )}            {item.href ? (              <a                href={item.href}                data-group-item={item.id}                title={showIconOnly ? item.label : undefined}                aria-label={item.label}                onPointerEnter={handlePointerEnter}                onPointerLeave={handlePointerLeave}                onFocus={handleFocus}                onBlur={handleBlur}                onClick={item.onClick}                className={itemClasses}              >                {content}              </a>            ) : (              <button                type="button"                data-group-item={item.id}                disabled={item.disabled}                title={showIconOnly ? item.label : undefined}                aria-label={item.label}                onPointerEnter={handlePointerEnter}                onPointerLeave={handlePointerLeave}                onFocus={handleFocus}                onBlur={handleBlur}                onClick={item.onClick}                className={itemClasses}              >                {content}              </button>            )}          </React.Fragment>        );      })}    </div>  );}export default ButtonGroup;

Registry item: /r/button-group.json