Dynamic Island
A pill that morphs between a compact status line and any number of named live-activity views, the same element reshaping via layout animation each time.
9:41
"use client";
import { Music2, 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 View = "call" | "timer" | "music" | null;
const BAR_DELAYS = [0, 0.18, 0.09, 0.27];
function EqBars() {
const reduce = useReducedMotion();
return (
<span aria-hidden="true" className="flex h-4 items-end gap-0.5">
{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: Number.POSITIVE_INFINITY, ease: "easeInOut", delay }}
style={{ scaleY: 0.6 }}
className="h-full w-0.5 origin-bottom rounded-full bg-background"
/>
))}
</span>
);
}
function formatClock(totalSeconds: number) {
const m = Math.floor(totalSeconds / 60);
const s = Math.round(totalSeconds) % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function DynamicIslandPreview() {
const [view, setView] = useState<View>(null);
const [seconds, setSeconds] = useState(154);
useEffect(() => {
if (view !== "timer") return;
const id = window.setInterval(() => setSeconds((current) => (current > 0 ? current - 1 : 0)), 1000);
return () => window.clearInterval(id);
}, [view]);
return (
<div className="flex w-full flex-col items-center gap-5">
<DynamicIsland
view={view}
compact={
<>
<span className="h-1.5 w-1.5 rounded-full bg-success" />
<span>9:41</span>
</>
}
>
<DynamicIslandView id="call">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wider text-background/60">Incoming call</span>
<span className="text-sm font-semibold">Priya</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 outline-none transition-colors duration-150 hover:bg-destructive/90"
>
<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-success text-white outline-none transition-colors duration-150 hover:bg-success/90"
>
<Phone className="h-3.5 w-3.5" />
</button>
</div>
</DynamicIslandView>
<DynamicIslandView id="timer">
<Timer className="h-4 w-4 text-warning" aria-hidden="true" />
<span className="text-[10px] uppercase tracking-wider text-background/60">Focus timer</span>
<span className="text-sm font-semibold">
<NumberTicker value={seconds} format={formatClock} />
</span>
</DynamicIslandView>
<DynamicIslandView id="music">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/15">
<Music2 className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<div className="flex flex-col text-left">
<span className="text-xs font-semibold leading-tight">Little Dark Age</span>
<span className="text-[10px] text-background/60">MGMT</span>
</div>
<EqBars />
</DynamicIslandView>
</DynamicIsland>
<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>
);
}
"use client";
// easeui.dev/components/motion/dynamic-island
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface DynamicIslandViewProps {
/** Matches the parent's `view` prop when this is the active view. */
id: string;
className?: string;
children?: ReactNode;
}
/** A named live-activity view. Read by the parent DynamicIsland; never rendered on its own. */
export function DynamicIslandView({ children }: DynamicIslandViewProps) {
return <>{children}</>;
}
export interface DynamicIslandProps {
/** Which view is active. `null` shows the compact pill. */
view: string | null;
/** Compact pill content, shown while no view is active. */
compact?: ReactNode;
/** One or more DynamicIslandView elements. */
children?: ReactNode;
className?: string;
}
// A deliberate exception to this library's usual bounce:0 springs — the
// island is meant to feel soft and a little elastic, like it's made of
// liquid, not a stiff panel. Reduced motion drops the bounce entirely below.
const MORPH_SPRING = { type: "spring", bounce: 0.35, duration: 0.6 } as const;
/**
* A pill that morphs between a compact status line and any number of named
* live-activity views — the same element reshaping via layout animation
* each time, rather than one panel swapping for another beside it.
*/
export function DynamicIsland({ view, compact, children, className }: DynamicIslandProps) {
const reduce = useReducedMotion();
const views = Children.toArray(children).filter(isValidElement) as ReactElement<DynamicIslandViewProps>[];
const active = views.find((item) => item.props.id === view);
return (
<motion.div
layout={!reduce}
transition={reduce ? { duration: 0 } : MORPH_SPRING}
style={{ borderRadius: active ? 28 : 9999, willChange: "transform" }}
className={cn(
"mx-auto flex w-fit max-w-full items-center justify-center overflow-hidden bg-foreground text-background",
className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<motion.div
key={active.props.id}
layout
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1, transition: { delay: reduce ? 0 : 0.08, duration: 0.15, ease: EASE_OUT } }}
exit={reduce ? undefined : { opacity: 0 }}
className={cn("flex items-center gap-3 p-4", active.props.className)}
>
{active.props.children}
</motion.div>
) : (
<motion.div
key="compact"
layout
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium"
>
{compact}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
Installation
$ bunx --bun shadcn add @easeui/dynamic-island
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/dynamic-island.tsx "use client"; // easeui.dev/components/motion/dynamic-island import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface DynamicIslandViewProps { /** Matches the parent's `view` prop when this is the active view. */ id: string; className?: string; children?: ReactNode; } /** A named live-activity view. Read by the parent DynamicIsland; never rendered on its own. */ export function DynamicIslandView({ children }: DynamicIslandViewProps) { return <>{children}</>; } export interface DynamicIslandProps { /** Which view is active. `null` shows the compact pill. */ view: string | null; /** Compact pill content, shown while no view is active. */ compact?: ReactNode; /** One or more DynamicIslandView elements. */ children?: ReactNode; className?: string; } // A deliberate exception to this library's usual bounce:0 springs — the // island is meant to feel soft and a little elastic, like it's made of // liquid, not a stiff panel. Reduced motion drops the bounce entirely below. const MORPH_SPRING = { type: "spring", bounce: 0.35, duration: 0.6 } as const; /** * A pill that morphs between a compact status line and any number of named * live-activity views — the same element reshaping via layout animation * each time, rather than one panel swapping for another beside it. */ export function DynamicIsland({ view, compact, children, className }: DynamicIslandProps) { const reduce = useReducedMotion(); const views = Children.toArray(children).filter(isValidElement) as ReactElement<DynamicIslandViewProps>[]; const active = views.find((item) => item.props.id === view); return ( <motion.div layout={!reduce} transition={reduce ? { duration: 0 } : MORPH_SPRING} style={{ borderRadius: active ? 28 : 9999, willChange: "transform" }} className={cn( "mx-auto flex w-fit max-w-full items-center justify-center overflow-hidden bg-foreground text-background", className, )} > <AnimatePresence mode="popLayout" initial={false}> {active ? ( <motion.div key={active.props.id} layout initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1, transition: { delay: reduce ? 0 : 0.08, duration: 0.15, ease: EASE_OUT } }} exit={reduce ? undefined : { opacity: 0 }} className={cn("flex items-center gap-3 p-4", active.props.className)} > {active.props.children} </motion.div> ) : ( <motion.div key="compact" layout initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.15, ease: EASE_OUT }} className="flex items-center gap-2 px-4 py-2 text-sm font-medium" > {compact} </motion.div> )} </AnimatePresence> </motion.div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ 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.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });components/motion/number-ticker.tsx "use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const EASE = [0.23, 1, 0.32, 1] as const; export interface NumberTickerProps { /** The number to display. Changing it rolls each digit to its new value. */ value: number; /** Formats the number before it is split into characters. Default: grouped with commas. */ format?: (value: number) => string; className?: string; } const defaultFormat = (value: number) => Math.round(value).toLocaleString("en-US"); /** * A number that rolls to a new value like an odometer: each character that * changes slides up and out while its replacement slides up into place. * Characters keep their slot counting from the right, so a comma or a new * leading digit never disturbs the ones already on screen. */ export function NumberTicker({ value, format = defaultFormat, className }: NumberTickerProps) { const reduce = useReducedMotion(); const text = format(value); const characters = text.split(""); return ( <span className={cn("inline-flex tabular-nums", className)}> <span aria-hidden="true" className="inline-flex"> {characters.map((char, index) => ( <span // biome-ignore lint/suspicious/noArrayIndexKey: the key is distance from the right edge, stable as digits are added. key={characters.length - index} className="relative inline-block overflow-hidden" > <span className="invisible">{char}</span> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={char} initial={reduce ? false : { y: "70%", opacity: 0 }} animate={{ y: "0%", opacity: 1 }} exit={reduce ? undefined : { y: "-70%", opacity: 0 }} transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className="absolute inset-0" > {char} </motion.span> </AnimatePresence> </span> ))} </span> <span className="sr-only">{text}</span> </span> ); }
API reference
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| DynamicIslandView | id | string | - | Matches the parent's `view` prop when this is the active view. |
| DynamicIslandView | className? | string | - | - |
| DynamicIsland | view | string | null | - | Which view is active. `null` shows the compact pill. |
| DynamicIsland | compact? | any | - | Compact pill content, shown while no view is active. |
| DynamicIsland | children? | any | - | One or more DynamicIslandView elements. |
| DynamicIsland | className? | string | - | - |
Updated