Skip to content
jjswnth/ui

Motion

Dynamic Island

An iOS-style Dynamic Island with eleven activities that collapse and reopen as you switch between them.

Hover to preview · click to pin

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/dynamic-island.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useEffect, useMemo, useState, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    export const islandStates = [
      "idle",
      "ring",
      "timer",
      "record",
      "music",
      "airdrop",
      "call",
      "screenrecord",
      "faceid",
      "charging",
      "maps",
    ] as const;
    
    export type IslandState = (typeof islandStates)[number];
    
    export const islandStateLabels: Record<IslandState, string> = {
      idle: "Idle",
      ring: "Ring",
      timer: "Timer",
      record: "Voice memo",
      music: "Music",
      airdrop: "AirDrop",
      call: "Call",
      screenrecord: "Screen record",
      faceid: "Face ID",
      charging: "Charging",
      maps: "Maps",
    };
    
    type Shape = { width: number; height: number; radius: number };
    
    /** The island is sized per state; everything else springs between these. */
    const shapes: Record<IslandState, Shape> = {
      idle: { width: 122, height: 36, radius: 18 },
      ring: { width: 194, height: 36, radius: 18 },
      timer: { width: 284, height: 66, radius: 24 },
      record: { width: 284, height: 38, radius: 19 },
      music: { width: 284, height: 145, radius: 30 },
      airdrop: { width: 284, height: 145, radius: 30 },
      call: { width: 284, height: 42, radius: 21 },
      screenrecord: { width: 284, height: 66, radius: 24 },
      faceid: { width: 194, height: 36, radius: 18 },
      charging: { width: 182, height: 36, radius: 18 },
      maps: { width: 284, height: 66, radius: 24 },
    };
    
    const spring = { type: "spring" as const, stiffness: 420, damping: 32, mass: 0.9 };
    
    /**
     * `corner-shape` gives true squircle corners in browsers that support it and is
     * ignored everywhere else, so the plain border radius stays as the fallback.
     */
    const squircle = { cornerShape: "squircle" } as unknown as React.CSSProperties;
    
    type DynamicIslandProps = {
      /** Which activity the island is showing. */
      state?: IslandState;
      /** Seconds the timer state counts down from. */
      timerFrom?: number;
      /** Bar count in the voice-memo visualiser. */
      recordBars?: number;
      className?: string;
    };
    
    export function DynamicIsland({
      state = "idle",
      timerFrom = 60,
      recordBars = 10,
      className,
    }: DynamicIslandProps) {
      // Every change routes through `idle` first, so the island collapses and then
      // reopens into its new shape the way iOS does.
      const [rendered, setRendered] = useState<IslandState>(state);
    
      useEffect(() => {
        if (state === rendered) return;
    
        setRendered("idle");
        const id = window.setTimeout(() => setRendered(state), 240);
        return () => window.clearTimeout(id);
        // Intentionally keyed on the incoming state only — `rendered` is the output.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [state]);
    
      const shape = shapes[rendered];
    
      return (
        <motion.div
          role="status"
          aria-live="polite"
          aria-label={`Dynamic island — ${islandStateLabels[rendered]}`}
          animate={{
            width: shape.width,
            height: shape.height,
            borderRadius: shape.radius,
          }}
          transition={spring}
          // Width and height are repeated in `style` so the island is correctly
          // sized in the server-rendered markup, before motion hydrates.
          style={{
            width: shape.width,
            height: shape.height,
            borderRadius: shape.radius,
            ...squircle,
          }}
          className={cn(
            "relative overflow-hidden bg-[#050505] text-white select-none",
            "shadow-[0_10px_30px_-12px_rgb(0_0_0/0.8)]",
            className,
          )}
        >
          {/* Fixed to the target size so content never reflows mid-animation. */}
          <div
            className="absolute top-0 left-1/2 -translate-x-1/2"
            style={{ width: shape.width, height: shape.height }}
          >
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={rendered}
                initial={{ opacity: 0, filter: "blur(6px)", y: 4 }}
                animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
                exit={{ opacity: 0, filter: "blur(6px)", y: -4 }}
                transition={{ duration: 0.18, ease: "easeOut" }}
                className="h-full w-full"
              >
                {rendered === "ring" && <Ring />}
                {rendered === "timer" && <Timer from={timerFrom} />}
                {rendered === "record" && <Record bars={recordBars} />}
                {rendered === "music" && <Music />}
                {rendered === "airdrop" && <AirDrop />}
                {rendered === "call" && <Call />}
                {rendered === "screenrecord" && <ScreenRecord />}
                {rendered === "faceid" && <FaceId />}
                {rendered === "charging" && <Charging />}
                {rendered === "maps" && <Maps />}
              </motion.div>
            </AnimatePresence>
          </div>
        </motion.div>
      );
    }
    
    /* -------------------------------------------------------------------------- */
    /*  Hooks                                                                      */
    /* -------------------------------------------------------------------------- */
    
    /** Seconds elapsed since mount, pausable. */
    function useTicker(running = true) {
      const [seconds, setSeconds] = useState(0);
    
      useEffect(() => {
        if (!running) return;
        const id = window.setInterval(() => setSeconds((value) => value + 1), 1000);
        return () => window.clearInterval(id);
      }, [running]);
    
      return seconds;
    }
    
    function clock(totalSeconds: number, padMinutes = false) {
      const safe = Math.max(0, Math.floor(totalSeconds));
      const minutes = Math.floor(safe / 60);
      const seconds = safe % 60;
      const head = padMinutes ? String(minutes).padStart(2, "0") : String(minutes);
      return `${head}:${String(seconds).padStart(2, "0")}`;
    }
    
    /* -------------------------------------------------------------------------- */
    /*  States                                                                     */
    /* -------------------------------------------------------------------------- */
    
    function Ring() {
      // The state demonstrates itself by flipping between ringer on and silent.
      const [silent, setSilent] = useState(false);
    
      useEffect(() => {
        const id = window.setInterval(() => setSilent((value) => !value), 2600);
        return () => window.clearInterval(id);
      }, []);
    
      return (
        <div className="flex h-full items-center justify-between pr-4 pl-2.5">
          <div className="relative grid size-7 place-items-center">
            <AnimatePresence>
              {silent && (
                <motion.span
                  key="chip"
                  initial={{ opacity: 0, scaleX: 0.4 }}
                  animate={{ opacity: 1, scaleX: 1 }}
                  exit={{ opacity: 0, scaleX: 0.4 }}
                  transition={spring}
                  className="absolute -left-[1px] -right-[11px] inset-y-[3px] rounded-full bg-[#ff3b30]"
                />
              )}
            </AnimatePresence>
    
            <motion.span
              className="relative"
              animate={silent ? { rotate: 0 } : { rotate: [0, -16, 13, -9, 6, -3, 0] }}
              transition={
                silent
                  ? { duration: 0.2 }
                  : { duration: 0.9, repeat: Infinity, repeatDelay: 1.3, ease: "easeInOut" }
              }
            >
              {silent ? <BellOffIcon /> : <BellIcon />}
            </motion.span>
          </div>
    
          <div className="relative h-4 w-14 overflow-hidden">
            <AnimatePresence mode="wait" initial={false}>
              <motion.span
                key={silent ? "silent" : "ring"}
                initial={{ y: 14, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -14, opacity: 0 }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className={cn(
                  "absolute inset-0 text-right text-[13px] font-medium",
                  silent ? "text-[#ff453a]" : "text-white/85",
                )}
              >
                {silent ? "Silent" : "Ring"}
              </motion.span>
            </AnimatePresence>
          </div>
        </div>
      );
    }
    
    function Timer({ from }: { from: number }) {
      const [running, setRunning] = useState(true);
      const [remaining, setRemaining] = useState(from);
    
      useEffect(() => {
        if (!running) return;
        const id = window.setInterval(
          () => setRemaining((value) => (value <= 0 ? 0 : value - 1)),
          1000,
        );
        return () => window.clearInterval(id);
      }, [running]);
    
      return (
        <div className="flex h-full items-center justify-between px-3">
          <div className="flex items-center gap-2.5">
            <button
              type="button"
              onClick={() => setRunning((value) => !value)}
              aria-label={running ? "Pause timer" : "Resume timer"}
              className="grid size-10 place-items-center rounded-full bg-[#8f6b1f] text-[#f5b70a] transition-transform active:scale-92"
            >
              {running ? <PauseIcon /> : <PlayIcon />}
            </button>
            <button
              type="button"
              onClick={() => {
                setRunning(false);
                setRemaining(from);
              }}
              aria-label="Cancel timer"
              className="grid size-10 place-items-center rounded-full bg-white/12 text-white transition-transform active:scale-92"
            >
              <CloseIcon />
            </button>
          </div>
    
          <div className="flex items-baseline gap-2 pr-1 text-[#f5b70a]">
            <span className="text-[15px]">Timer</span>
            <span className="text-[30px] leading-none font-light tabular-nums">
              {clock(remaining)}
            </span>
          </div>
        </div>
      );
    }
    
    function Record({ bars }: { bars: number }) {
      const elapsed = useTicker();
    
      return (
        <div className="flex h-full items-center justify-between px-4">
          <Waveform count={bars} className="bg-[#ff3b30]" maxHeight={16} />
          <span className="text-[14px] tabular-nums text-[#ff453a]">
            {clock(elapsed, true)}
          </span>
        </div>
      );
    }
    
    function Music() {
      const elapsed = useTicker();
      const total = 120;
      const played = Math.min(elapsed + 8, total);
    
      return (
        <div className="flex h-full flex-col justify-between p-3.5">
          <div className="flex items-start gap-3">
            <div className="size-12 shrink-0 rounded-xl bg-[linear-gradient(150deg,#ff7a45,#f472b6_45%,#818cf8)]" />
    
            <div className="min-w-0 flex-1 pt-0.5 text-[15px] leading-[1.15] font-semibold">
              <p className="truncate">Glow</p>
              <p className="truncate text-white/55">Echo</p>
            </div>
    
            <Waveform count={5} className="bg-[#4f8ef7]" maxHeight={12} width={3} />
          </div>
    
          <div className="flex items-center gap-2 text-[10px] tabular-nums text-white/45">
            <span>{clock(played, true)}</span>
            <div className="h-1 flex-1 overflow-hidden rounded-full bg-white/18">
              <motion.div
                className="h-full rounded-full bg-white/70"
                animate={{ width: `${(played / total) * 100}%` }}
                transition={{ ease: "linear", duration: 0.9 }}
              />
            </div>
            <span>-{clock(total - played, true)}</span>
          </div>
    
          <div className="flex items-center justify-center gap-9 text-white">
            <button type="button" aria-label="Previous track" className="active:scale-90">
              <SkipIcon />
            </button>
            <button type="button" aria-label="Play" className="active:scale-90">
              <PlayIcon size={18} />
            </button>
            <button type="button" aria-label="Next track" className="rotate-180 active:scale-90">
              <SkipIcon />
            </button>
          </div>
        </div>
      );
    }
    
    function AirDrop() {
      return (
        <div className="flex h-full flex-col justify-between p-3.5">
          <div className="flex items-start gap-3">
            <div className="min-w-0 flex-1">
              <AirDropIcon />
              <p className="mt-1 text-[15px] leading-tight font-semibold">AirDrop</p>
              <p className="text-[12px] leading-snug text-white/55">
                Gxuri would like to share
              </p>
              <p className="text-[12px] leading-snug text-white/55">23 photos</p>
            </div>
    
            <div className="size-16 shrink-0 rounded-xl bg-[linear-gradient(165deg,#5b8def_0%,#8fb8d8_38%,#c2703f_62%,#2f5d3a_100%)]" />
          </div>
    
          <div className="grid grid-cols-2 gap-2">
            <button
              type="button"
              className="rounded-full bg-white/16 py-1.5 text-[13px] font-medium transition-transform active:scale-97"
            >
              Decline
            </button>
            <button
              type="button"
              className="rounded-full bg-[#0a6cff] py-1.5 text-[13px] font-medium transition-transform active:scale-97"
            >
              Accept
            </button>
          </div>
        </div>
      );
    }
    
    function Call() {
      const elapsed = useTicker();
    
      return (
        <div className="flex h-full items-center justify-between px-4">
          <div className="flex items-center gap-2 text-[#30d158]">
            <PhoneIcon />
            <span className="text-[14px] tabular-nums">{clock(elapsed, true)}</span>
          </div>
          <Waveform count={22} className="bg-[#30d158]" maxHeight={14} width={2} gap={2} />
        </div>
      );
    }
    
    function ScreenRecord() {
      const elapsed = useTicker();
    
      return (
        <div className="flex h-full items-center justify-between px-4">
          <div>
            <div className="flex items-center gap-1.5">
              <motion.span
                className="size-2 rounded-full bg-[#ff453a]"
                animate={{ opacity: [1, 0.35, 1] }}
                transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
              />
              <span className="text-[14px] tabular-nums text-[#ff453a]">
                {clock(elapsed)}
              </span>
            </div>
            <p className="mt-0.5 text-[13px] text-white/90">Screen Recording</p>
          </div>
    
          <button
            type="button"
            aria-label="Stop screen recording"
            className="grid size-9 place-items-center rounded-full border-2 border-white/85 transition-transform active:scale-92"
          >
            <span className="size-3.5 rounded-[3px] bg-[#ff3b30]" />
          </button>
        </div>
      );
    }
    
    function FaceId() {
      return (
        <div className="flex h-full items-center justify-between pr-4 pl-3">
          <motion.span
            animate={{ opacity: [0.55, 1, 0.55] }}
            transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
            className="text-white"
          >
            <FaceIdIcon />
          </motion.span>
          <span className="text-[13px] font-medium text-white/85">Face ID</span>
        </div>
      );
    }
    
    function Charging() {
      return (
        <div className="flex h-full items-center justify-between pr-4 pl-3">
          <span className="text-[#30d158]">
            <BoltIcon />
          </span>
          <div className="flex items-center gap-2">
            <span className="text-[13px] font-medium tabular-nums text-white/85">80%</span>
            <div className="relative h-3.5 w-7 rounded-[4px] border border-white/40 p-[2px]">
              <motion.div
                className="h-full rounded-[1.5px] bg-[#30d158]"
                animate={{ width: ["55%", "85%", "55%"] }}
                transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
              />
              <span className="absolute top-1/2 -right-[3px] h-1.5 w-[2px] -translate-y-1/2 rounded-r-[1px] bg-white/40" />
            </div>
          </div>
        </div>
      );
    }
    
    function Maps() {
      return (
        <div className="flex h-full items-center justify-between px-3.5">
          <div className="flex items-center gap-3">
            <div className="grid size-10 place-items-center rounded-xl bg-[#0a84ff] text-white">
              <TurnIcon />
            </div>
            <div className="leading-tight">
              <p className="text-[14px] font-semibold">Turn right</p>
              <p className="text-[12px] text-white/55">onto Market St</p>
            </div>
          </div>
          <span className="text-[17px] font-medium tabular-nums text-[#30d158]">300 ft</span>
        </div>
      );
    }
    
    /* -------------------------------------------------------------------------- */
    /*  Pieces                                                                     */
    /* -------------------------------------------------------------------------- */
    
    function Waveform({
      count,
      className,
      maxHeight,
      width = 3,
      gap = 3,
    }: {
      count: number;
      className?: string;
      maxHeight: number;
      width?: number;
      gap?: number;
    }) {
      // Stable per-bar rhythm so the visualiser looks organic rather than uniform.
      const seeds = useMemo(
        () =>
          Array.from({ length: count }, (_, index) => ({
            duration: 0.7 + ((index * 37) % 60) / 100,
            delay: ((index * 53) % 70) / 100,
            peak: 0.45 + ((index * 29) % 55) / 100,
          })),
        [count],
      );
    
      return (
        <div className="flex items-center" style={{ gap, height: maxHeight }}>
          {seeds.map((seed, index) => (
            <motion.span
              key={index}
              className={cn("rounded-full", className)}
              style={{ width }}
              animate={{
                height: [
                  maxHeight * 0.25,
                  maxHeight * seed.peak,
                  maxHeight * 0.35,
                  maxHeight,
                  maxHeight * 0.3,
                ],
              }}
              transition={{
                duration: seed.duration * 2,
                delay: seed.delay,
                repeat: Infinity,
                repeatType: "mirror",
                ease: "easeInOut",
              }}
            />
          ))}
        </div>
      );
    }
    
    /* -------------------------------------------------------------------------- */
    /*  Icons                                                                      */
    /* -------------------------------------------------------------------------- */
    
    function Svg({ children, size = 16, ...rest }: { children: ReactNode; size?: number } & React.SVGProps<SVGSVGElement>) {
      return (
        <svg
          width={size}
          height={size}
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
          {...rest}
        >
          {children}
        </svg>
      );
    }
    
    function BellIcon() {
      return (
        <Svg size={17}>
          <path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
          <path d="M13.7 21a2 2 0 0 1-3.4 0" />
        </Svg>
      );
    }
    
    function BellOffIcon() {
      return (
        <Svg size={17}>
          <path d="M18 8a6 6 0 0 0-9.3-5" />
          <path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h14" />
          <path d="M13.7 21a2 2 0 0 1-3.4 0" />
          <path d="m2 2 20 20" />
        </Svg>
      );
    }
    
    function PauseIcon() {
      return (
        <Svg size={16} fill="currentColor" stroke="none">
          <rect x="6" y="4" width="4" height="16" rx="1.5" />
          <rect x="14" y="4" width="4" height="16" rx="1.5" />
        </Svg>
      );
    }
    
    function PlayIcon({ size = 16 }: { size?: number }) {
      return (
        <Svg size={size} fill="currentColor" stroke="none">
          <path d="M8 5.2a1 1 0 0 1 1.5-.87l9 6.8a1 1 0 0 1 0 1.74l-9 6.8A1 1 0 0 1 8 18.8Z" />
        </Svg>
      );
    }
    
    function SkipIcon() {
      return (
        <Svg size={18} fill="currentColor" stroke="none">
          <path d="M11 6.6a.8.8 0 0 1 1.2-.7l7 5.4a.8.8 0 0 1 0 1.4l-7 5.4a.8.8 0 0 1-1.2-.7Z" />
          <path d="M3 6.6a.8.8 0 0 1 1.2-.7l7 5.4a.8.8 0 0 1 0 1.4l-7 5.4a.8.8 0 0 1-1.2-.7Z" />
        </Svg>
      );
    }
    
    function CloseIcon() {
      return (
        <Svg size={16}>
          <path d="M18 6 6 18M6 6l12 12" />
        </Svg>
      );
    }
    
    function PhoneIcon() {
      return (
        <Svg size={15} fill="currentColor" stroke="none">
          <path d="M6.6 3.3a1.6 1.6 0 0 1 2.2.5l1.3 2a1.6 1.6 0 0 1-.3 2.1l-1 .8a10 10 0 0 0 4.5 4.5l.8-1a1.6 1.6 0 0 1 2.1-.3l2 1.3a1.6 1.6 0 0 1 .5 2.2l-1 1.6a2.4 2.4 0 0 1-2.8 1A16.6 16.6 0 0 1 5 8.7a2.4 2.4 0 0 1 1-2.8Z" />
        </Svg>
      );
    }
    
    function AirDropIcon() {
      return (
        <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path
            d="M5.5 13.5a7.5 7.5 0 0 1 13 0"
            stroke="#0a84ff"
            strokeWidth="2"
            strokeLinecap="round"
          />
          <path
            d="M8.4 16.4a4 4 0 0 1 7.2 0"
            stroke="#0a84ff"
            strokeWidth="2"
            strokeLinecap="round"
          />
          <circle cx="12" cy="19.4" r="1.6" fill="#0a84ff" />
        </svg>
      );
    }
    
    function FaceIdIcon() {
      return (
        <Svg size={18} strokeWidth={1.8}>
          <path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2" />
          <path d="M9 10v1.5M15 10v1.5M12 10v3.2M9.5 16a3.4 3.4 0 0 0 5 0" />
        </Svg>
      );
    }
    
    function BoltIcon() {
      return (
        <Svg size={17} fill="currentColor" stroke="none">
          <path d="M13.4 2.2a.5.5 0 0 1 .9.42L13 10h4.6a.6.6 0 0 1 .47.97l-8 10.5a.5.5 0 0 1-.88-.42L10.5 14H6a.6.6 0 0 1-.47-.97Z" />
        </Svg>
      );
    }
    
    function TurnIcon() {
      return (
        <Svg size={18} strokeWidth={2.2}>
          <path d="M6 19v-6a4 4 0 0 1 4-4h7" />
          <path d="m14 6 3.5 3L14 12" />
        </Svg>
      );
    }

Props

PropTypeDefaultDescription
stateIslandState"idle"Which activity to show. Changing it collapses to idle, then reopens.
timerFromnumber60Seconds the timer activity counts down from.
recordBarsnumber10Bar count in the voice-memo visualiser.
classNamestringMerged onto the island itself.