Expandable Control
Click-to-expand button and chip controls that reveal a label or trailing action through spring layout continuity.
Preview
"use client";
import { Bell, X } from "lucide-react";
import {
ExpandableButton,
ExpandableChip,
} from "@/components/motion/expandable-control";
export function ExpandableControlPreview() {
return (
<div className="flex flex-col items-center gap-6">
<div className="flex items-center gap-2">
<ExpandableButton
icon={<Bell className="size-4" />}
label="Notifications"
/>
</div>
<div className="flex items-center gap-2">
<ExpandableChip
label="React"
actionIcon={<X className="size-3.5" />}
actionLabel="Remove React"
/>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/expandable-control
import {
AnimatePresence,
type HTMLMotionProps,
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type MouseEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Matches the Motion Patterns "Layout continuity" recipe so the surface
// keeps its identity while its footprint changes.
const CONTINUITY_SPRING: Transition = {
type: "spring",
stiffness: 220,
damping: 17,
mass: 0.85,
};
const CONTINUITY_LABEL: Variants = {
hidden: { opacity: 0, filter: "blur(4px)" },
visible: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(4px)" },
};
type ExpandableStateProps = {
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
};
export interface ExpandableButtonProps
extends Omit<HTMLMotionProps<"button">, "children">,
ExpandableStateProps {
icon: ReactNode;
label: ReactNode;
}
export interface ExpandableChipProps extends ExpandableStateProps {
label: ReactNode;
actionIcon: ReactNode;
actionLabel: string;
onAction?: () => void;
collapseOnAction?: boolean;
disabled?: boolean;
className?: string;
labelClassName?: string;
actionClassName?: string;
}
function useExpandableState({
expanded,
defaultExpanded = false,
onExpandedChange,
}: ExpandableStateProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = expanded !== undefined;
const currentExpanded = expanded ?? internalExpanded;
const setExpanded = useCallback(
(nextExpanded: boolean) => {
if (!isControlled) {
setInternalExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[isControlled, onExpandedChange],
);
return [currentExpanded, setExpanded] as const;
}
export function ExpandableButton({
icon,
label,
expanded,
defaultExpanded,
onExpandedChange,
className,
onClick,
disabled,
type = "button",
"aria-label": ariaLabel,
...props
}: ExpandableButtonProps) {
const reduce = useReducedMotion();
const [isExpanded, setExpanded] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setExpanded(!isExpanded);
}
};
const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;
return (
<motion.span layout transition={transition} className="inline-flex">
<motion.button
{...props}
layout
type={type}
aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)}
aria-expanded={isExpanded}
disabled={disabled}
className={cn(
"relative inline-flex h-11 min-w-11 items-center overflow-hidden rounded-full border border-border bg-transparent p-1 pr-1 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className,
)}
style={{ borderRadius: 9999 }}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={transition}
>
<motion.span
layout="position"
className="grid size-9 shrink-0 place-items-center"
aria-hidden="true"
transition={transition}
>
{icon}
</motion.span>
<AnimatePresence mode="popLayout" initial={false}>
{isExpanded ? (
<motion.span
key="label"
layout
variants={CONTINUITY_LABEL}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "visible"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={transition}
className="relative inline-flex w-max items-center whitespace-nowrap pr-3"
aria-hidden="true"
>
{label}
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
</motion.span>
);
}
export function ExpandableChip({
label,
actionIcon,
actionLabel,
onAction,
collapseOnAction = true,
expanded,
defaultExpanded,
onExpandedChange,
disabled,
className,
labelClassName,
actionClassName,
}: ExpandableChipProps) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [isExpanded, setExpanded] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const handleAction = () => {
onAction?.();
if (collapseOnAction) {
setExpanded(false);
triggerRef.current?.focus();
}
};
const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;
return (
<motion.span layout transition={transition} className="inline-flex">
<motion.div
layout
className={cn(
"inline-flex h-10 items-center overflow-hidden rounded-full border border-border bg-transparent text-sm text-foreground has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-inset has-[:focus-visible]:ring-ring",
className,
)}
style={{ borderRadius: 9999 }}
transition={transition}
>
<motion.button
layout="position"
ref={triggerRef}
type="button"
aria-expanded={isExpanded}
disabled={disabled}
className={cn(
"h-full whitespace-nowrap py-0 pl-3 font-medium outline-none disabled:pointer-events-none disabled:opacity-50",
isExpanded ? "pr-1" : "pr-3",
labelClassName,
)}
onClick={() => setExpanded(!isExpanded)}
transition={transition}
>
{label}
</motion.button>
<motion.span
variants={CONTINUITY_LABEL}
className={cn(
"relative inline-flex h-10 shrink-0 items-center overflow-hidden",
isExpanded ? "w-8" : "w-0",
)}
aria-hidden={!isExpanded}
initial={false}
animate={
reduce
? { opacity: isExpanded ? 1 : 0 }
: isExpanded
? "visible"
: "hidden"
}
transition={transition}
>
<motion.button
type="button"
aria-label={actionLabel}
disabled={disabled || !isExpanded}
className={cn(
"flex h-10 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground disabled:pointer-events-none",
disabled && "opacity-50",
actionClassName,
)}
onClick={handleAction}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={transition}
>
<span aria-hidden="true">{actionIcon}</span>
</motion.button>
</motion.span>
</motion.div>
</motion.span>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/expandable-control
import {
AnimatePresence,
type HTMLMotionProps,
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type MouseEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Matches the Motion Patterns "Layout continuity" recipe so the surface
// keeps its identity while its footprint changes.
const CONTINUITY_SPRING: Transition = {
type: "spring",
stiffness: 220,
damping: 17,
mass: 0.85,
};
const CONTINUITY_LABEL: Variants = {
hidden: { opacity: 0, filter: "blur(4px)" },
visible: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(4px)" },
};
type ExpandableStateProps = {
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
};
export interface ExpandableButtonProps
extends Omit<HTMLMotionProps<"button">, "children">,
ExpandableStateProps {
icon: ReactNode;
label: ReactNode;
}
export interface ExpandableChipProps extends ExpandableStateProps {
label: ReactNode;
actionIcon: ReactNode;
actionLabel: string;
onAction?: () => void;
collapseOnAction?: boolean;
disabled?: boolean;
className?: string;
labelClassName?: string;
actionClassName?: string;
}
function useExpandableState({
expanded,
defaultExpanded = false,
onExpandedChange,
}: ExpandableStateProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = expanded !== undefined;
const currentExpanded = expanded ?? internalExpanded;
const setExpanded = useCallback(
(nextExpanded: boolean) => {
if (!isControlled) {
setInternalExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[isControlled, onExpandedChange],
);
return [currentExpanded, setExpanded] as const;
}
export function ExpandableButton({
icon,
label,
expanded,
defaultExpanded,
onExpandedChange,
className,
onClick,
disabled,
type = "button",
"aria-label": ariaLabel,
...props
}: ExpandableButtonProps) {
const reduce = useReducedMotion();
const [isExpanded, setExpanded] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setExpanded(!isExpanded);
}
};
const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;
return (
<motion.span layout transition={transition} className="inline-flex">
<motion.button
{...props}
layout
type={type}
aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)}
aria-expanded={isExpanded}
disabled={disabled}
className={cn(
"relative inline-flex h-11 min-w-11 items-center overflow-hidden rounded-full border border-border bg-transparent p-1 pr-1 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className,
)}
style={{ borderRadius: 9999 }}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={transition}
>
<motion.span
layout="position"
className="grid size-9 shrink-0 place-items-center"
aria-hidden="true"
transition={transition}
>
{icon}
</motion.span>
<AnimatePresence mode="popLayout" initial={false}>
{isExpanded ? (
<motion.span
key="label"
layout
variants={CONTINUITY_LABEL}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "visible"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={transition}
className="relative inline-flex w-max items-center whitespace-nowrap pr-3"
aria-hidden="true"
>
{label}
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
</motion.span>
);
}
export function ExpandableChip({
label,
actionIcon,
actionLabel,
onAction,
collapseOnAction = true,
expanded,
defaultExpanded,
onExpandedChange,
disabled,
className,
labelClassName,
actionClassName,
}: ExpandableChipProps) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [isExpanded, setExpanded] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const handleAction = () => {
onAction?.();
if (collapseOnAction) {
setExpanded(false);
triggerRef.current?.focus();
}
};
const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;
return (
<motion.span layout transition={transition} className="inline-flex">
<motion.div
layout
className={cn(
"inline-flex h-10 items-center overflow-hidden rounded-full border border-border bg-transparent text-sm text-foreground has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-inset has-[:focus-visible]:ring-ring",
className,
)}
style={{ borderRadius: 9999 }}
transition={transition}
>
<motion.button
layout="position"
ref={triggerRef}
type="button"
aria-expanded={isExpanded}
disabled={disabled}
className={cn(
"h-full whitespace-nowrap py-0 pl-3 font-medium outline-none disabled:pointer-events-none disabled:opacity-50",
isExpanded ? "pr-1" : "pr-3",
labelClassName,
)}
onClick={() => setExpanded(!isExpanded)}
transition={transition}
>
{label}
</motion.button>
<motion.span
variants={CONTINUITY_LABEL}
className={cn(
"relative inline-flex h-10 shrink-0 items-center overflow-hidden",
isExpanded ? "w-8" : "w-0",
)}
aria-hidden={!isExpanded}
initial={false}
animate={
reduce
? { opacity: isExpanded ? 1 : 0 }
: isExpanded
? "visible"
: "hidden"
}
transition={transition}
>
<motion.button
type="button"
aria-label={actionLabel}
disabled={disabled || !isExpanded}
className={cn(
"flex h-10 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground disabled:pointer-events-none",
disabled && "opacity-50",
actionClassName,
)}
onClick={handleAction}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={transition}
>
<span aria-hidden="true">{actionIcon}</span>
</motion.button>
</motion.span>
</motion.div>
</motion.span>
);
}
API Reference
ExpandableButton
iconReactNode—labelReactNode—className?string—ExpandableChip
labelReactNode—actionIconReactNode—actionLabelstring—onAction?(() => void)—collapseOnAction?booleantruedisabled?boolean—className?string—labelClassName?string—actionClassName?string—expanded?boolean—defaultExpanded?boolean—onExpandedChange?((expanded: boolean) => void)—Related components
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter with a liquid neck that stretches and pinches, then crisp content fades in on top. The Morph variant reveals its full-size panel from the corner nearest the trigger on one synchronized spring clip. Click or hover trigger, controlled or uncontrolled.
Theme Toggle
Theme toggle button that repaints the whole page through the View Transition API — a rectangle or circle clip-path reveal, or slats that open across the screen like a shutter.
Card
Composable card with header, title, description, action, content and footer parts. Outlined or muted surfaces, standard or compact spacing, and an opt-in selectable state with press feedback that composes with Checkbox, Radio and Button.
Updated