Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Preview
9:41
TSXcomponents/previews/blocks/dynamic-island.preview.tsx
"use client";
import { Music, Phone, PhoneOff, Timer } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/motion/button";
import {
DynamicIsland,
DynamicIslandView,
} from "@/components/motion/dynamic-island";
import { NumberTicker } from "@/components/motion/number-ticker";
type IslandView = "call" | "timer" | "music" | null;
const BAR_DELAYS = [0, 0.18, 0.09, 0.27];
function EqBars() {
const reduce = useReducedMotion();
return (
<span className="flex h-4 items-end gap-0.5" aria-hidden>
{BAR_DELAYS.map((delay) => (
<motion.span
key={delay}
animate={reduce ? undefined : { scaleY: [0.4, 1, 0.55, 0.9, 0.4] }}
transition={{
duration: 1.1,
repeat: Infinity,
ease: "easeInOut",
delay,
}}
className="h-full w-0.5 origin-bottom rounded-full bg-(--color-success)"
style={{ scaleY: 0.6 }}
/>
))}
</span>
);
}
function formatClock(totalSeconds: number) {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function DynamicIslandPreview() {
const [view, setView] = useState<IslandView>(null);
const [seconds, setSeconds] = useState(154);
useEffect(() => {
if (view !== "timer") return;
const id = window.setInterval(() => {
setSeconds((s) => (s > 0 ? s - 1 : 0));
}, 1000);
return () => window.clearInterval(id);
}, [view]);
return (
<div className="flex w-full flex-col items-center gap-4">
{/* Fixed-height, top-aligned zone: the island stays pinned at the top
like under a notch and unfurls downward into reserved space. */}
<div className="flex h-32 w-full items-start justify-center pt-2">
<DynamicIsland
view={view}
compact={
<>
<span className="h-1.5 w-1.5 rounded-full bg-(--color-success)" />
<span>9:41</span>
</>
}
>
<DynamicIslandView id="call" className="gap-4">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wider opacity-60">
Incoming call
</span>
<span className="text-sm font-semibold">Saurabh</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="Decline"
onClick={() => setView(null)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-white"
>
<PhoneOff className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="Accept"
onClick={() => setView(null)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-(--color-success) text-white"
>
<Phone className="h-3.5 w-3.5" />
</button>
</div>
</DynamicIslandView>
<DynamicIslandView id="timer" className="gap-3">
<Timer className="h-4 w-4 text-(--color-warning)" />
<span className="text-[10px] uppercase tracking-wider opacity-60">
Timer
</span>
<NumberTicker
value={seconds}
format={formatClock}
startOnView={false}
duration={0.5}
className="text-sm font-semibold"
/>
</DynamicIslandView>
<DynamicIslandView id="music" className="gap-3">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/15">
<Music className="h-3.5 w-3.5" />
</span>
<div className="flex flex-col text-left">
<span className="text-xs font-semibold leading-tight">
Midnight City
</span>
<span className="text-[10px] opacity-60">M83</span>
</div>
<EqBars />
</DynamicIslandView>
</DynamicIsland>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button size="sm" variant="secondary" onClick={() => setView("call")}>
Call
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
setSeconds(154);
setView("timer");
}}
>
Timer
</Button>
<Button size="sm" variant="secondary" onClick={() => setView("music")}>
Music
</Button>
<Button size="sm" variant="ghost" onClick={() => setView(null)}>
Dismiss
</Button>
</div>
</div>
);
}
TSXcomponents/motion/dynamic-island.tsx
"use client";
// beui.dev/components/blocks/dynamic-island
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type IslandContextValue = {
view: string | null;
};
const IslandContext = createContext<IslandContextValue | null>(null);
// Shell physics in Apple's duration/bounce form one long perceptual glide with barely-there bounce, identical in
// both directions. The shell animates real width/height (not transforms), so
// slots are never scale-distorted.
const SHELL_SPRING = {
type: "spring",
duration: 0.8,
bounce: 0.2,
} as const;
// Content gets a touch more life than the shell.
const CONTENT_SPRING = {
type: "spring",
duration: 0.8,
bounce: 0.35,
} as const;
// Constant radius — never animated. The browser clamps it to half the shell
// height, so the pill-to-rounded-rect morph falls out of the resize for free
// with zero chance of corner glitches.
const RADIUS = 32;
// iPhone pill dimensions. Also the shell's pre-measure animate target: if the
// first commit already has a view active (e.g. a click replayed after
// hydration), the shell blooms from the pill instead of rendering expanded
// with no animation. Lives in `animate`, not `initial`, so server and client
// markup agree.
const PILL_WIDTH = 126;
const PILL_HEIGHT = 37;
/** Tracks the natural size of the content so the shell can spring to it. */
function useContentSize() {
const ref = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
// Synchronous mount measure: the shell must own explicit dimensions before
// the first interaction. ResizeObserver fires async after mount — a quick
// first press could beat it, leaving the shell auto-sized so the view
// snapped open instead of springing.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setSize({ width: el.offsetWidth, height: el.offsetHeight });
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
setSize({ width: el.offsetWidth, height: el.offsetHeight });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, size] as const;
}
function Slot({
keyId,
children,
className,
}: {
keyId: string;
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<motion.div
key={keyId}
initial={
reduce
? { opacity: 0, filter: "blur(0px)" }
: { opacity: 0, scale: 0.9, y: -8, filter: "blur(5px)" }
}
animate={
reduce
? { opacity: 1, filter: "blur(0px)" }
: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }
}
// Exit gets sucked up into the pill — fast, blur-free, before the
// shrinking shell can clip it.
exit={
reduce
? { opacity: 0, filter: "blur(0px)", transition: { duration: 0.1 } }
: {
opacity: 0,
scale: 0.9,
y: -6,
filter: "blur(0px)",
transition: { duration: 0.08, ease: EASE_OUT },
}
}
// One spring drives transform, opacity and blur together — no per
// property tweens, no delays. Content travels with the shell.
transition={reduce ? { duration: 0.15 } : CONTENT_SPRING}
// Anchored to the pill line: content unfurls downward out of it and is
// sucked back up into it.
style={{ transformOrigin: "top center" }}
className={cn("flex items-center justify-center", className)}
>
{children}
</motion.div>
);
}
export interface DynamicIslandProps {
/** Active view id. `null` shows the compact pill. */
view: string | null;
/** Compact pill content, shown when no view is active. */
compact?: ReactNode;
/** DynamicIslandView elements. */
children?: ReactNode;
className?: string;
}
export function DynamicIsland({
view,
compact,
children,
className,
}: DynamicIslandProps) {
const reduce = useReducedMotion();
const expanded = view !== null;
const [sizerRef, size] = useContentSize();
const contextValue = useMemo(() => ({ view }), [view]);
return (
<IslandContext.Provider value={contextValue}>
<motion.div
role="status"
aria-live="polite"
initial={false}
animate={
size
? { width: size.width, height: size.height }
: { width: PILL_WIDTH, height: PILL_HEIGHT }
}
transition={reduce ? { duration: 0 } : SHELL_SPRING}
style={{ borderRadius: RADIUS }}
// items-start pins content to the top edge while the shell springs, so
// expansion reads as unfurling downward out of the pill. Top-align the
// island in its parent (like under a notch) to complete the effect.
className={cn(
"relative inline-flex items-start justify-center overflow-hidden",
"bg-foreground text-background shadow-2xl",
className,
)}
>
{/* w-max keeps this at the natural size of the active content; the
shell springs toward it. */}
<div ref={sizerRef} className="w-max">
<AnimatePresence mode="popLayout" initial={false}>
{!expanded && compact ? (
<Slot
keyId="compact"
// iPhone pill proportions: ~126 x 37.
className="min-h-[37px] min-w-[126px] gap-2 px-4 py-1.5 text-xs font-medium"
>
{compact}
</Slot>
) : null}
</AnimatePresence>
{children}
</div>
</motion.div>
</IslandContext.Provider>
);
}
export interface DynamicIslandViewProps {
/** Matches the parent `view` prop when active. */
id: string;
children: ReactNode;
className?: string;
}
export function DynamicIslandView({
id,
children,
className,
}: DynamicIslandViewProps) {
const ctx = useContext(IslandContext);
if (!ctx)
throw new Error("DynamicIslandView must be used inside <DynamicIsland>");
const active = ctx.view === id;
return (
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<Slot keyId={id} className={cn("px-6 py-4", className)}>
{children}
</Slot>
) : null}
</AnimatePresence>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/dynamic-island
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/hooks/use-hover-capable.ts
"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
TSXcomponents/motion/dynamic-island.tsx
"use client";
// beui.dev/components/blocks/dynamic-island
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type IslandContextValue = {
view: string | null;
};
const IslandContext = createContext<IslandContextValue | null>(null);
// Shell physics in Apple's duration/bounce form one long perceptual glide with barely-there bounce, identical in
// both directions. The shell animates real width/height (not transforms), so
// slots are never scale-distorted.
const SHELL_SPRING = {
type: "spring",
duration: 0.8,
bounce: 0.2,
} as const;
// Content gets a touch more life than the shell.
const CONTENT_SPRING = {
type: "spring",
duration: 0.8,
bounce: 0.35,
} as const;
// Constant radius — never animated. The browser clamps it to half the shell
// height, so the pill-to-rounded-rect morph falls out of the resize for free
// with zero chance of corner glitches.
const RADIUS = 32;
// iPhone pill dimensions. Also the shell's pre-measure animate target: if the
// first commit already has a view active (e.g. a click replayed after
// hydration), the shell blooms from the pill instead of rendering expanded
// with no animation. Lives in `animate`, not `initial`, so server and client
// markup agree.
const PILL_WIDTH = 126;
const PILL_HEIGHT = 37;
/** Tracks the natural size of the content so the shell can spring to it. */
function useContentSize() {
const ref = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
// Synchronous mount measure: the shell must own explicit dimensions before
// the first interaction. ResizeObserver fires async after mount — a quick
// first press could beat it, leaving the shell auto-sized so the view
// snapped open instead of springing.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setSize({ width: el.offsetWidth, height: el.offsetHeight });
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
setSize({ width: el.offsetWidth, height: el.offsetHeight });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, size] as const;
}
function Slot({
keyId,
children,
className,
}: {
keyId: string;
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<motion.div
key={keyId}
initial={
reduce
? { opacity: 0, filter: "blur(0px)" }
: { opacity: 0, scale: 0.9, y: -8, filter: "blur(5px)" }
}
animate={
reduce
? { opacity: 1, filter: "blur(0px)" }
: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }
}
// Exit gets sucked up into the pill — fast, blur-free, before the
// shrinking shell can clip it.
exit={
reduce
? { opacity: 0, filter: "blur(0px)", transition: { duration: 0.1 } }
: {
opacity: 0,
scale: 0.9,
y: -6,
filter: "blur(0px)",
transition: { duration: 0.08, ease: EASE_OUT },
}
}
// One spring drives transform, opacity and blur together — no per
// property tweens, no delays. Content travels with the shell.
transition={reduce ? { duration: 0.15 } : CONTENT_SPRING}
// Anchored to the pill line: content unfurls downward out of it and is
// sucked back up into it.
style={{ transformOrigin: "top center" }}
className={cn("flex items-center justify-center", className)}
>
{children}
</motion.div>
);
}
export interface DynamicIslandProps {
/** Active view id. `null` shows the compact pill. */
view: string | null;
/** Compact pill content, shown when no view is active. */
compact?: ReactNode;
/** DynamicIslandView elements. */
children?: ReactNode;
className?: string;
}
export function DynamicIsland({
view,
compact,
children,
className,
}: DynamicIslandProps) {
const reduce = useReducedMotion();
const expanded = view !== null;
const [sizerRef, size] = useContentSize();
const contextValue = useMemo(() => ({ view }), [view]);
return (
<IslandContext.Provider value={contextValue}>
<motion.div
role="status"
aria-live="polite"
initial={false}
animate={
size
? { width: size.width, height: size.height }
: { width: PILL_WIDTH, height: PILL_HEIGHT }
}
transition={reduce ? { duration: 0 } : SHELL_SPRING}
style={{ borderRadius: RADIUS }}
// items-start pins content to the top edge while the shell springs, so
// expansion reads as unfurling downward out of the pill. Top-align the
// island in its parent (like under a notch) to complete the effect.
className={cn(
"relative inline-flex items-start justify-center overflow-hidden",
"bg-foreground text-background shadow-2xl",
className,
)}
>
{/* w-max keeps this at the natural size of the active content; the
shell springs toward it. */}
<div ref={sizerRef} className="w-max">
<AnimatePresence mode="popLayout" initial={false}>
{!expanded && compact ? (
<Slot
keyId="compact"
// iPhone pill proportions: ~126 x 37.
className="min-h-[37px] min-w-[126px] gap-2 px-4 py-1.5 text-xs font-medium"
>
{compact}
</Slot>
) : null}
</AnimatePresence>
{children}
</div>
</motion.div>
</IslandContext.Provider>
);
}
export interface DynamicIslandViewProps {
/** Matches the parent `view` prop when active. */
id: string;
children: ReactNode;
className?: string;
}
export function DynamicIslandView({
id,
children,
className,
}: DynamicIslandViewProps) {
const ctx = useContext(IslandContext);
if (!ctx)
throw new Error("DynamicIslandView must be used inside <DynamicIsland>");
const active = ctx.view === id;
return (
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<Slot keyId={id} className={cn("px-6 py-4", className)}>
{children}
</Slot>
) : null}
</AnimatePresence>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/number-ticker.tsx
"use client";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert locale group separators (commas). Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString()
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
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;
}
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-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
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>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"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>
);
});
TSXcomponents/motion/button/stateful.tsx
"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>
);
});
TSXcomponents/motion/magnetic.tsx
"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
DynamicIsland
viewstring | nullActive view id. `null` shows the compact pill.
—compact?anyCompact pill content, shown when no view is active.
—children?anyDynamicIslandView elements.
—className?string—DynamicIslandView
idstringMatches the parent `view` prop when active.
—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated