A pill button with staggered letter flip, squash hover, and click scale.
import { ArrowUpRight, LinkIcon } from "lucide-react";
import {
StaggeredButton,
StaggeredButtonIcon,
} from "@/components/ui/staggered-button";
function StaggeredButtonDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<StaggeredButton>
<StaggeredButtonIcon>
<ChatBubbleIcon />
</StaggeredButtonIcon>
Send
</StaggeredButton>
<StaggeredButton asChild>
<a href="#projects">
<StaggeredButtonIcon>
<LinkIcon className="size-4" />
</StaggeredButtonIcon>
I'm a link
</a>
</StaggeredButton>
<StaggeredButton>
Trailing icon
<StaggeredButtonIcon>
<ArrowUpRight className="size-4" />
</StaggeredButtonIcon>
</StaggeredButton>
</div>
);
}npm install @radix-ui/react-slot class-variance-authoritybun add @radix-ui/react-slot class-variance-authoritydeno add @radix-ui/react-slot class-variance-authoritypnpm add @radix-ui/react-slot class-variance-authorityyarn add @radix-ui/react-slot class-variance-authorityCopy and paste the following code into your project.
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import {
Children,
type ComponentProps,
type CSSProperties,
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
const staggeredButtonVariants = cva(
[
"group/staggered relative inline-grid min-h-8.5 cursor-pointer select-none rounded-[2.5em] font-medium leading-none",
"bg-[#171717] p-0 text-base text-white no-underline outline-none",
"[--index-offset:1] [-webkit-tap-highlight-color:transparent]",
"after:pointer-events-none after:absolute after:inset-[-0.125em] after:z-1 after:content-['']",
"after:transition-[box-shadow,scale] after:duration-[300ms,450ms]",
"after:ease-[cubic-bezier(0.32,0.72,0,1),cubic-bezier(0.625,0.05,0,1)]",
"focus-visible:after:scale-[1.065_1.095] focus-visible:after:shadow-[0_0_0_0.125em_#000]",
].join(" "),
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "",
icon: "aspect-square",
},
},
}
);
const innerClassName = cva(
"z-1 col-start-1 row-start-1 flex size-full items-center justify-center",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "gap-[0.3em] px-[0.8em] py-[0.4em]",
icon: "p-[0.4em]",
},
},
}
);
const splitCharClassName = [
"inline-block",
"[text-shadow:0_calc(var(--staggered-char-direction)*1.1em)_currentColor]",
"[transition:translate_0.3s_cubic-bezier(0.625,0.05,0,1),text-shadow_0.1s_0.15s_ease]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-hover/staggered:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-focus-visible/staggered:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:hover]:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:focus-visible]:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-hover/staggered:[transition:translate_0.5s_calc((var(--char)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1),text-shadow_0.175s_calc((var(--char)-var(--index-offset))*0.024s+0.25s)_ease]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-focus-visible/staggered:[transition:translate_0.5s_calc((var(--char)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1),text-shadow_0.175s_calc((var(--char)-var(--index-offset))*0.024s+0.25s)_ease]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:hover]:[transition:translate_0.5s_calc((var(--char)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1),text-shadow_0.175s_calc((var(--char)-var(--index-offset))*0.024s+0.25s)_ease]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:focus-visible]:[transition:translate_0.5s_calc((var(--char)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1),text-shadow_0.175s_calc((var(--char)-var(--index-offset))*0.024s+0.25s)_ease]",
].join(" ");
const iconClassName = cva(
[
"flex size-[0.7em] shrink-0 items-center justify-center text-[2rem] [--index:0]",
"[filter:drop-shadow(0_calc(var(--staggered-char-direction)*1.1em)_0_currentColor)]",
"[transition:translate_0.3s_cubic-bezier(0.625,0.05,0,1)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-hover/staggered:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-focus-visible/staggered:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:hover]:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:focus-visible]:[translate:0_calc(var(--staggered-char-direction)*-1.1em)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-hover/staggered:[transition:translate_0.5s_calc((var(--index)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-focus-visible/staggered:[transition:translate_0.5s_calc((var(--index)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:hover]:[transition:translate_0.5s_calc((var(--index)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:focus-visible]:[transition:translate_0.5s_calc((var(--index)-var(--index-offset))*0.024s)_cubic-bezier(0.625,0.05,0,1)]",
].join(" "),
{
defaultVariants: {
exitDirection: "down",
},
variants: {
exitDirection: {
down: "[--staggered-char-direction:-1]",
up: "[--staggered-char-direction:1]",
},
},
}
);
type StaggeredButtonVariant = NonNullable<
VariantProps<typeof staggeredButtonVariants>["variant"]
>;
type StaggeredButtonProps = ComponentProps<"button"> &
VariantProps<typeof staggeredButtonVariants> & {
asChild?: boolean;
};
type StaggeredButtonSlot =
| { key: string; node: ReactElement<StaggeredButtonIconProps>; type: "icon" }
| { key: string; text: string; type: "label" };
interface StaggeredButtonIconProps {
children?: ReactNode;
exitDirection?: VariantProps<typeof iconClassName>["exitDirection"];
}
function StaggeredButtonIcon({
children,
exitDirection = "down",
}: StaggeredButtonIconProps) {
return (
<span className="flex shrink-0 items-center justify-center overflow-hidden [clip-path:inset(0_0_-15%)]">
<span className={iconClassName({ exitDirection })}>{children}</span>
</span>
);
}
StaggeredButtonIcon.displayName = "StaggeredButtonIcon";
function isStaggeredButtonIcon(
node: ReactNode
): node is ReactElement<StaggeredButtonIconProps> {
return isValidElement(node) && node.type === StaggeredButtonIcon;
}
function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (isStaggeredButtonIcon(node)) {
return "";
}
if (isValidElement<{ children?: ReactNode }>(node)) {
return getNodeText(node.props.children);
}
return "";
}
function partitionChildren(children: ReactNode): StaggeredButtonSlot[] {
const items: StaggeredButtonSlot[] = [];
let iconOrdinal = 0;
let labelOrdinal = 0;
let labelBuffer: ReactNode[] = [];
const flushLabel = () => {
const text = getNodeText(labelBuffer).trim();
if (text.length > 0) {
items.push({ key: `label-${labelOrdinal}`, text, type: "label" });
labelOrdinal += 1;
}
labelBuffer = [];
};
for (const node of Children.toArray(children)) {
if (isStaggeredButtonIcon(node)) {
flushLabel();
items.push({ key: `icon-${iconOrdinal}`, node, type: "icon" });
iconOrdinal += 1;
continue;
}
labelBuffer.push(node);
}
flushLabel();
return items;
}
function SplitLabel({ text }: { text: string }) {
const nodes: ReactNode[] = [];
let charIndex = 0;
for (const [index, character] of Array.from(text).entries()) {
if (character === " ") {
nodes.push(" ");
continue;
}
charIndex += 1;
const direction = charIndex % 2 === 1 ? 1 : -1;
nodes.push(
<span
aria-hidden="true"
className={splitCharClassName}
key={`${index}-${character}`}
style={
{
"--char": charIndex,
"--staggered-char-direction": direction,
} as CSSProperties
}
>
{character}
</span>
);
}
return nodes;
}
function StaggeredButtonLabel({
children,
variant,
}: {
children?: string | null;
variant: StaggeredButtonVariant;
}) {
if (!children) {
return null;
}
switch (variant) {
case "icon":
return <span className="sr-only">{children}</span>;
case "default":
return (
<>
<span className="sr-only">{children}</span>
<span
aria-hidden="true"
className="block overflow-hidden whitespace-nowrap leading-none [clip-path:inset(0_0_-15%)]"
>
<SplitLabel text={children} />
</span>
</>
);
default: {
const _exhaustive: never = variant;
return _exhaustive;
}
}
}
function StaggeredButtonInner({
items,
variant,
}: {
items: StaggeredButtonSlot[];
variant: StaggeredButtonVariant;
}) {
return (
<>
<span
className={cn(
"col-start-1 row-start-1 size-full rounded-[inherit] bg-[inherit] p-0",
"transition-[scale,transform] duration-[450ms,150ms]",
"ease-[cubic-bezier(0.625,0.05,0,1),cubic-bezier(0.4,0,0.2,1)]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-hover/staggered:scale-[1.065_1.095]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:group-focus-visible/staggered:scale-[1.065_1.095]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:hover]:scale-[1.065_1.095]",
"motion-safe:[@media(hover:hover)_and_(pointer:fine)]:in-[[data-hover]:focus-visible]:scale-[1.065_1.095]",
"group-active/staggered:[transform:scale(0.955,0.925)]"
)}
/>
<span className={innerClassName({ variant })}>
{items.map((item) => {
switch (item.type) {
case "icon":
return cloneElement(item.node, { key: item.key });
case "label":
return (
<StaggeredButtonLabel key={item.key} variant={variant}>
{item.text}
</StaggeredButtonLabel>
);
default: {
const _exhaustive: never = item;
return _exhaustive;
}
}
})}
</span>
</>
);
}
function StaggeredButton({
asChild = false,
children,
className,
type,
variant = "default",
...props
}: StaggeredButtonProps) {
const Comp = asChild ? Slot : "button";
const resolvedVariant = variant ?? "default";
const sourceChildren =
asChild && isValidElement<{ children?: ReactNode }>(children)
? children.props.children
: children;
const items = partitionChildren(sourceChildren);
const content = (
<StaggeredButtonInner items={items} variant={resolvedVariant} />
);
const composedChild =
asChild && isValidElement(children)
? cloneElement(children, undefined, content)
: content;
return (
<Comp
className={cn(
staggeredButtonVariants({ variant: resolvedVariant }),
className
)}
data-slot="staggered-button"
type={asChild ? undefined : (type ?? "button")}
{...props}
>
{composedChild}
</Comp>
);
}
export type { StaggeredButtonIconProps, StaggeredButtonProps };
export { StaggeredButton, StaggeredButtonIcon, staggeredButtonVariants };import { ArrowUpRight, LinkIcon } from "lucide-react";
import {
StaggeredButton,
StaggeredButtonIcon,
} from "@/components/ui/staggered-button";<StaggeredButton>
<StaggeredButtonIcon>
<ChatBubbleIcon />
</StaggeredButtonIcon>
Send
</StaggeredButton><StaggeredButton>Only Text</StaggeredButton><StaggeredButton className="rounded-md bg-linear-to-r bg-transparent from-gray-300 to-gray-500 text-[#101010]">
Different style
<StaggeredButtonIcon exitDirection="up">
<ArrowUpRight className="size-4" />
</StaggeredButtonIcon>
</StaggeredButton><StaggeredButton asChild>
<a href="#projects">
<StaggeredButtonIcon>
<LinkIcon className="size-4" />
</StaggeredButtonIcon>
I'm a link
</a>
</StaggeredButton><StaggeredButton>
Icon exit up
<StaggeredButtonIcon exitDirection="up">
<ArrowUpRight className="size-4" />
</StaggeredButtonIcon>
</StaggeredButton><StaggeredButton aria-label="Chat" variant="icon">
<StaggeredButtonIcon>
<ChatBubbleIcon />
</StaggeredButtonIcon>
</StaggeredButton>aria-hidden; the original label is exposed with a screen-reader-only node.prefers-reduced-motion: reduce, or without a fine pointer, the letter flip and hover squash are disabled.aria-label when there is no visible label.Renders a native button by default. Pass asChild to merge props onto the child element via Radix Slot, typically an a or Next.js Link. The visible label is taken from the child text so the stagger animation still runs.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Label text and optional StaggeredButtonIcon. Order is preserved. With asChild, pass a single element. |
| asChild | boolean | false | Render as the child element instead of a button. |
| variant | "default" | "icon" | "default" | Pill with label, or square icon button. |
StaggeredButtonIcon accepts exitDirection ("up" | "down", default "down") and the icon as children.