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.
Preview
The final PDF with every signature and the audit trail.
Itemised billing for the period, ready for accounting.
Notifications
Choose what reaches your inbox.
Review order
Check the details before you confirm.
- Plan
- Team, yearly
- Seats
- 12
- Billing email
- billing@acme.co
Studio, Lisbon
Desks for 12 people, booked for the March offsite.
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/motion/button";
import {
CARD_TARGET_CLASS,
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/motion/card";
import { Checkbox } from "@/components/motion/checkbox";
import { Switch } from "@/components/motion/switch";
const DOCUMENTS = [
{ id: "contract", title: "Signed contract", description: "The final PDF with every signature and the audit trail." },
{ id: "invoice", title: "Invoice", description: "Itemised billing for the period, ready for accounting." },
];
const SUMMARY = [
{ label: "Plan", value: "Team, yearly" },
{ label: "Seats", value: "12" },
{ label: "Billing email", value: "billing@acme.co" },
];
function DocumentChoice({ id, title, description }: (typeof DOCUMENTS)[number]) {
const [checked, setChecked] = useState(id === "contract");
const descriptionId = useId();
return (
<Card size="compact" interactive selected={checked}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription id={descriptionId}>{description}</CardDescription>
<CardAction>
<Checkbox
checked={checked}
onCheckedChange={setChecked}
aria-label={title}
aria-describedby={descriptionId}
className={CARD_TARGET_CLASS}
/>
</CardAction>
</CardHeader>
</Card>
);
}
export function CardPreview() {
const [notifications, setNotifications] = useState(true);
const [digest, setDigest] = useState(false);
return (
<div className="grid w-full max-w-3xl items-start gap-4 md:grid-cols-2">
<div className="flex flex-col gap-3">
{DOCUMENTS.map((document) => (
<DocumentChoice key={document.id} {...document} />
))}
<Card variant="muted" size="compact">
<CardHeader>
<CardTitle as="h3">Notifications</CardTitle>
<CardDescription>Choose what reaches your inbox.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<span>Mentions and replies</span>
<Switch checked={notifications} onCheckedChange={setNotifications} ariaLabel="Mentions and replies" />
</div>
<div className="flex items-center justify-between gap-4">
<span>Weekly digest</span>
<Switch checked={digest} onCheckedChange={setDigest} ariaLabel="Weekly digest" />
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle as="h3">Review order</CardTitle>
<CardDescription>Check the details before you confirm.</CardDescription>
<CardAction>
<Button variant="ghost" size="sm">
Edit
</Button>
</CardAction>
</CardHeader>
<CardContent>
<dl className="divide-y divide-border">
{SUMMARY.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-4 py-2.5 first:pt-0 last:pb-0">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-medium text-foreground">{row.value}</dd>
</div>
))}
</dl>
</CardContent>
<CardFooter className="justify-end">
<Button variant="secondary" size="sm">
Back
</Button>
<Button size="sm">Confirm order</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden pt-0">
{/* biome-ignore lint/performance/noImgElement: plain img keeps the copy-paste preview portable (no next/image host config). */}
<img
src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=720&q=80"
alt="Bright open-plan studio with long desks and plants"
width={720}
height={405}
loading="lazy"
className="aspect-video w-full object-cover"
/>
<CardHeader>
<CardTitle as="h3">Studio, Lisbon</CardTitle>
<CardDescription>Desks for 12 people, booked for the March offsite.</CardDescription>
</CardHeader>
<CardFooter className="justify-between">
<span className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">€1,840</span> · 3 days
</span>
<Button variant="secondary" size="sm">
View booking
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/card
import { motion, useReducedMotion } from "motion/react";
import { type ComponentPropsWithRef, createContext, useContext } from "react";
import { SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type CardVariant = "outlined" | "muted";
export type CardSize = "default" | "compact";
const CardSizeContext = createContext<CardSize>("default");
const VARIANT_CLASS: Record<CardVariant, string> = {
outlined: "border-border bg-background",
muted: "border-transparent bg-muted",
};
// Each part pads itself on the inline axis, so a part can be left out without
// the others losing their edges. The root owns the block padding and the gap.
const ROOT_SIZE_CLASS: Record<CardSize, string> = {
default: "gap-6 py-6",
compact: "gap-4 py-4",
};
const INSET_CLASS: Record<CardSize, string> = {
default: "px-6",
compact: "px-4",
};
/**
* Spread onto the one control that owns a selectable card (a Checkbox or
* RadioGroupItem root) to stretch its hit area over the whole card. Any other
* interactive element inside that card needs `relative z-10` to stay on top.
*/
export const CARD_TARGET_CLASS = "after:absolute after:inset-0";
export interface CardProps extends ComponentPropsWithRef<"div"> {
/** Default "outlined". */
variant?: CardVariant;
/** Spacing for every part. Default "default". */
size?: CardSize;
/** Hover and press feedback, for a card that is one target as a whole. */
interactive?: boolean;
/** Marks a selectable card as chosen. Visual only; the control inside carries the state. */
selected?: boolean;
}
export function Card({
variant = "outlined",
size = "default",
interactive = false,
selected = false,
className,
children,
...props
}: CardProps) {
const reduce = useReducedMotion();
return (
<CardSizeContext.Provider value={size}>
<motion.div
{...(props as React.ComponentProps<typeof motion.div>)}
data-slot="card"
data-selected={selected || undefined}
whileTap={interactive && !reduce ? { scale: 0.985 } : undefined}
transition={SPRING_PRESS}
className={cn(
"relative flex flex-col rounded-2xl border text-card-foreground",
"transition-[border-color,box-shadow,background-color] duration-200",
VARIANT_CLASS[variant],
ROOT_SIZE_CLASS[size],
interactive && "cursor-pointer hover:border-border-strong has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-foreground",
selected && "border-primary shadow-[0_0_0_1px_var(--primary)] hover:border-primary",
className,
)}
>
{children}
</motion.div>
</CardSizeContext.Provider>
);
}
export type CardHeaderProps = ComponentPropsWithRef<"div">;
/** Title and description, with an optional CardAction pinned to the top right. */
export function CardHeader({ className, ...props }: CardHeaderProps) {
const size = useContext(CardSizeContext);
return (
<div
{...props}
data-slot="card-header"
className={cn(
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5",
"has-data-[slot=card-action]:grid-cols-[1fr_auto]",
INSET_CLASS[size],
className,
)}
/>
);
}
export interface CardTitleProps extends ComponentPropsWithRef<"div"> {
/** Heading level, when the card starts a section. Default "div". */
as?: "div" | "h2" | "h3" | "h4";
}
export function CardTitle({ as: Tag = "div", className, ...props }: CardTitleProps) {
return (
<Tag
{...props}
data-slot="card-title"
className={cn("text-[15px] leading-snug font-semibold text-foreground", className)}
/>
);
}
export type CardDescriptionProps = ComponentPropsWithRef<"p">;
export function CardDescription({ className, ...props }: CardDescriptionProps) {
return (
<p
{...props}
data-slot="card-description"
className={cn("text-sm leading-relaxed text-muted-foreground", className)}
/>
);
}
export type CardActionProps = ComponentPropsWithRef<"div">;
/** Sits beside the title and description inside CardHeader. */
export function CardAction({ className, ...props }: CardActionProps) {
return (
// Flex, not block: an inline-flex control (Checkbox, Radio) would
// otherwise sit on a text baseline that moves when its mark appears.
<div
{...props}
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 flex items-center self-start justify-self-end",
className,
)}
/>
);
}
export type CardContentProps = ComponentPropsWithRef<"div">;
export function CardContent({ className, ...props }: CardContentProps) {
const size = useContext(CardSizeContext);
return <div {...props} data-slot="card-content" className={cn("text-sm", INSET_CLASS[size], className)} />;
}
export type CardFooterProps = ComponentPropsWithRef<"div">;
export function CardFooter({ className, ...props }: CardFooterProps) {
const size = useContext(CardSizeContext);
return (
<div
{...props}
data-slot="card-footer"
className={cn("flex items-center gap-2", INSET_CLASS[size], className)}
/>
);
}
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 files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/components/motion/card
import { motion, useReducedMotion } from "motion/react";
import { type ComponentPropsWithRef, createContext, useContext } from "react";
import { SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type CardVariant = "outlined" | "muted";
export type CardSize = "default" | "compact";
const CardSizeContext = createContext<CardSize>("default");
const VARIANT_CLASS: Record<CardVariant, string> = {
outlined: "border-border bg-background",
muted: "border-transparent bg-muted",
};
// Each part pads itself on the inline axis, so a part can be left out without
// the others losing their edges. The root owns the block padding and the gap.
const ROOT_SIZE_CLASS: Record<CardSize, string> = {
default: "gap-6 py-6",
compact: "gap-4 py-4",
};
const INSET_CLASS: Record<CardSize, string> = {
default: "px-6",
compact: "px-4",
};
/**
* Spread onto the one control that owns a selectable card (a Checkbox or
* RadioGroupItem root) to stretch its hit area over the whole card. Any other
* interactive element inside that card needs `relative z-10` to stay on top.
*/
export const CARD_TARGET_CLASS = "after:absolute after:inset-0";
export interface CardProps extends ComponentPropsWithRef<"div"> {
/** Default "outlined". */
variant?: CardVariant;
/** Spacing for every part. Default "default". */
size?: CardSize;
/** Hover and press feedback, for a card that is one target as a whole. */
interactive?: boolean;
/** Marks a selectable card as chosen. Visual only; the control inside carries the state. */
selected?: boolean;
}
export function Card({
variant = "outlined",
size = "default",
interactive = false,
selected = false,
className,
children,
...props
}: CardProps) {
const reduce = useReducedMotion();
return (
<CardSizeContext.Provider value={size}>
<motion.div
{...(props as React.ComponentProps<typeof motion.div>)}
data-slot="card"
data-selected={selected || undefined}
whileTap={interactive && !reduce ? { scale: 0.985 } : undefined}
transition={SPRING_PRESS}
className={cn(
"relative flex flex-col rounded-2xl border text-card-foreground",
"transition-[border-color,box-shadow,background-color] duration-200",
VARIANT_CLASS[variant],
ROOT_SIZE_CLASS[size],
interactive && "cursor-pointer hover:border-border-strong has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-foreground",
selected && "border-primary shadow-[0_0_0_1px_var(--primary)] hover:border-primary",
className,
)}
>
{children}
</motion.div>
</CardSizeContext.Provider>
);
}
export type CardHeaderProps = ComponentPropsWithRef<"div">;
/** Title and description, with an optional CardAction pinned to the top right. */
export function CardHeader({ className, ...props }: CardHeaderProps) {
const size = useContext(CardSizeContext);
return (
<div
{...props}
data-slot="card-header"
className={cn(
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5",
"has-data-[slot=card-action]:grid-cols-[1fr_auto]",
INSET_CLASS[size],
className,
)}
/>
);
}
export interface CardTitleProps extends ComponentPropsWithRef<"div"> {
/** Heading level, when the card starts a section. Default "div". */
as?: "div" | "h2" | "h3" | "h4";
}
export function CardTitle({ as: Tag = "div", className, ...props }: CardTitleProps) {
return (
<Tag
{...props}
data-slot="card-title"
className={cn("text-[15px] leading-snug font-semibold text-foreground", className)}
/>
);
}
export type CardDescriptionProps = ComponentPropsWithRef<"p">;
export function CardDescription({ className, ...props }: CardDescriptionProps) {
return (
<p
{...props}
data-slot="card-description"
className={cn("text-sm leading-relaxed text-muted-foreground", className)}
/>
);
}
export type CardActionProps = ComponentPropsWithRef<"div">;
/** Sits beside the title and description inside CardHeader. */
export function CardAction({ className, ...props }: CardActionProps) {
return (
// Flex, not block: an inline-flex control (Checkbox, Radio) would
// otherwise sit on a text baseline that moves when its mark appears.
<div
{...props}
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 flex items-center self-start justify-self-end",
className,
)}
/>
);
}
export type CardContentProps = ComponentPropsWithRef<"div">;
export function CardContent({ className, ...props }: CardContentProps) {
const size = useContext(CardSizeContext);
return <div {...props} data-slot="card-content" className={cn("text-sm", INSET_CLASS[size], className)} />;
}
export type CardFooterProps = ComponentPropsWithRef<"div">;
export function CardFooter({ className, ...props }: CardFooterProps) {
const size = useContext(CardSizeContext);
return (
<div
{...props}
data-slot="card-footer"
className={cn("flex items-center gap-2", INSET_CLASS[size], className)}
/>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
ariaLabel?: string;
className?: string;
}
export function Switch({
checked,
onCheckedChange,
disabled,
label,
ariaLabel,
className,
}: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
Card
variant?"outlined" | "muted"Default "outlined".
outlinedsize?"compact" | "default"Spacing for every part. Default "default".
defaultinteractive?booleanHover and press feedback, for a card that is one target as a whole.
falseselected?booleanMarks a selectable card as chosen. Visual only; the control inside carries the state.
falseclassName?string—CardHeader
className?string—CardTitle
as?"div" | "h2" | "h3" | "h4"Heading level, when the card starts a section. Default "div".
divclassName?string—CardDescription
className?string—CardAction
className?string—CardContent
className?string—CardFooter
className?string—Related components
Date Range Picker
Composable date range picker with root, trigger, popover content, calendar, header, grid, summary, footer, clear and preset parts, plus a shared state hook. Compact inline and dropdown compositions, direct month and year selection, and full keyboard navigation.
Animated Color Selector
Composable color swatches with a spring-gliding selection ring, press feedback, native radio keyboard navigation, and controlled or uncontrolled state.
Checkbox
Form choice control with a draw-on checkmark, spring press feedback and indeterminate state support.
Updated