Expanding Toggle Button
A shadcn toggle button that expands from an icon into an icon-and-label action while keeping the icon anchored.
buttonwidth-animation
"use client";
import * as React from "react";
import {
animate,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ButtonProps = React.ComponentPropsWithoutRef<typeof Button>;
type ButtonClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
type ExpandingToggleButtonSize = "default" | "xs" | "sm" | "lg";
const COLLAPSED_WIDTH: Record<ExpandingToggleButtonSize, number> = {
default: 32,
xs: 24,
sm: 28,
lg: 36,
};
const ICON_SIZE: Record<ExpandingToggleButtonSize, number> = {
default: 16,
xs: 12,
sm: 14,
lg: 18,
};
const LABEL_OUTER_PADDING: Record<ExpandingToggleButtonSize, number> = {
default: 10,
xs: 8,
sm: 10,
lg: 10,
};
const LABEL_ICON_GAP = 6;
const MORPH_TRANSITION: Transition = {
duration: 0.2,
ease: [0.22, 1, 0.36, 1],
};
const REDUCED_TRANSITION: Transition = { duration: 0 };
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
export type ExpandingToggleButtonProps = Omit<
ButtonProps,
| "aria-label"
| "aria-pressed"
| "children"
| "onClick"
| "size"
| "style"
> & {
/** Controlled active state. */
active?: boolean;
/** Initial active state for uncontrolled usage. */
defaultActive?: boolean;
/** Called after the button requests an active-state change. */
onActiveChange?: (active: boolean) => void;
/** Icon shown while the button is inactive. */
icon: React.ReactNode;
/** Optional icon shown while the button is active. */
activeIcon?: React.ReactNode;
/** Content revealed while the button is active. */
label: React.ReactNode;
/** Accessible name used while the button is inactive. */
inactiveLabel: string;
/** Accessible name used while the button is active. */
activeLabel: string;
/** Direction the label grows toward while the icon stays anchored. */
expandFrom?: "start" | "end";
/** Text-button size; the inactive width matches its icon-only counterpart. */
size?: ExpandingToggleButtonSize;
onClick?: (event: ButtonClickEvent) => void;
};
export const ExpandingToggleButton = React.forwardRef<
HTMLButtonElement,
ExpandingToggleButtonProps
>(function ExpandingToggleButton(
{
active,
defaultActive = false,
onActiveChange,
icon,
activeIcon,
label,
inactiveLabel,
activeLabel,
expandFrom = "end",
variant = "outline",
size = "lg",
disabled,
className,
onClick,
type = "button",
...props
},
ref,
) {
const [internalActive, setInternalActive] = React.useState(defaultActive);
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const contentRef = React.useRef<HTMLSpanElement | null>(null);
const hasMeasuredRef = React.useRef(false);
const shouldReduceMotion = useReducedMotion();
const controlled = active !== undefined;
const isActive = controlled ? active : internalActive;
const transition = shouldReduceMotion
? REDUCED_TRANSITION
: MORPH_TRANSITION;
const resolvedSize = size ?? "lg";
const collapsedWidth = COLLAPSED_WIDTH[resolvedSize];
const iconSize = ICON_SIZE[resolvedSize];
const labelOuterPadding = LABEL_OUTER_PADDING[resolvedSize];
const iconReserve =
collapsedWidth / 2 + iconSize / 2 + LABEL_ICON_GAP;
const setButtonRef = React.useCallback(
(node: HTMLButtonElement | null) => {
buttonRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
useIsomorphicLayoutEffect(() => {
const button = buttonRef.current;
const content = contentRef.current;
if (!button || !content) return;
const styles = getComputedStyle(button);
const expandedWidth = Math.ceil(
content.getBoundingClientRect().width +
Number.parseFloat(styles.paddingLeft) +
Number.parseFloat(styles.paddingRight) +
Number.parseFloat(styles.borderLeftWidth) +
Number.parseFloat(styles.borderRightWidth),
);
const targetWidth = isActive ? expandedWidth : collapsedWidth;
if (!hasMeasuredRef.current || shouldReduceMotion) {
button.style.width = `${targetWidth}px`;
hasMeasuredRef.current = true;
return;
}
const controls = animate(button, { width: targetWidth }, MORPH_TRANSITION);
return () => controls.stop();
}, [collapsedWidth, isActive, label, shouldReduceMotion]);
const handleClick = React.useCallback(
(event: ButtonClickEvent) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
const nextActive = !isActive;
if (!controlled) {
setInternalActive(nextActive);
}
onActiveChange?.(nextActive);
}, [controlled, disabled, isActive, onActiveChange, onClick],
);
const labelSlot = (
<motion.span
aria-hidden={!isActive}
data-slot="expanding-toggle-button-label"
initial={false}
animate={{ opacity: isActive ? 1 : 0 }}
transition={transition}
className="inline-flex shrink-0 items-center gap-1"
style={
expandFrom === "start"
? { paddingLeft: labelOuterPadding }
: { paddingRight: labelOuterPadding }
}
>
{label}
</motion.span>
);
const iconSpacer = (
<span
aria-hidden="true"
data-slot="expanding-toggle-button-icon-spacer"
className="h-full shrink-0"
style={{ width: iconReserve }}
/>
);
return (
<span
data-slot="expanding-toggle-button-anchor"
className="relative inline-flex shrink-0 align-middle"
style={{ width: collapsedWidth, height: collapsedWidth }}
>
<Button
ref={setButtonRef}
type={type}
size={resolvedSize}
variant={variant}
disabled={disabled}
aria-label={isActive ? activeLabel : inactiveLabel}
aria-pressed={isActive}
data-active={isActive}
data-slot="expanding-toggle-button"
onClick={handleClick}
style={{ width: collapsedWidth }}
className={cn(
"absolute top-0 gap-0! overflow-hidden p-0! transition-colors will-change-[width] motion-reduce:transition-none",
expandFrom === "start" ? "right-0 justify-end" : "left-0 justify-start",
className,
)}
{...props}
>
<span
ref={contentRef}
className="inline-flex h-full shrink-0 items-center"
>
{expandFrom === "start" ? labelSlot : iconSpacer}
{expandFrom === "start" ? iconSpacer : labelSlot}
</span>
<span
aria-hidden="true"
data-icon={expandFrom === "start" ? "inline-end" : "inline-start"}
data-slot="expanding-toggle-button-icon"
className={cn(
"absolute inset-y-0 flex items-center justify-center",
expandFrom === "start" ? "-right-px" : "-left-px",
)}
style={{ width: collapsedWidth }}
>
<span
className="relative block shrink-0"
style={{ width: iconSize, height: iconSize }}
>
<motion.span
initial={false}
animate={{ opacity: isActive && activeIcon ? 0 : 1 }}
transition={transition}
className="absolute inset-0 flex items-center justify-center [&_svg]:size-full!"
>
{icon}
</motion.span>
{activeIcon ? (
<motion.span
initial={false}
animate={{ opacity: isActive ? 1 : 0 }}
transition={transition}
className="absolute inset-0 flex items-center justify-center [&_svg]:size-full!"
>
{activeIcon}
</motion.span>
) : null}
</span>
</span>
</Button>
</span>
);
});