shadimage
spark-ui
componentdynamic-islandsource: ok · refreshed

Dynamic Island

A functional iPhone-inspired island that morphs between compact and expanded content.

npx shadcn add "https://spark-ui-olive.vercel.app/r/dynamic-island.json"
free

Visual style

playful

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion

Files1 · source cached 10/8/2026, 3:31:43 AM

registry/spark-ui/dynamic-island.tsx
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import * as React from "react";

export interface DynamicIslandProps {
  compact?: React.ReactNode;
  expanded?: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
  expandLabel?: string;
  collapseLabel?: string;
}

export function DynamicIsland({
  compact,
  expanded,
  open,
  defaultOpen = false,
  onOpenChange,
  className,
  expandLabel = "Expand Dynamic Island",
  collapseLabel = "Collapse Dynamic Island",
}: DynamicIslandProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const [isRejecting, setIsRejecting] = React.useState(false);
  const restoreFocus = React.useRef(false);
  const reducedMotion = useReducedMotion();
  const isOpen = open ?? uncontrolledOpen;
  const isPanelOpen = isOpen && expanded != null;
  const isEmpty = compact == null && expanded == null;
  const isPillOpen = isOpen && expanded == null && !isEmpty;

  const triggerRef = React.useCallback((node: HTMLButtonElement | null) => {
    if (node && restoreFocus.current) {
      node.focus();
      restoreFocus.current = false;
    }
  }, []);

  const setOpen = (nextOpen: boolean) => {
    if (open === undefined) setUncontrolledOpen(nextOpen);
    onOpenChange?.(nextOpen);
  };

  const collapse = () => {
    restoreFocus.current = true;
    setOpen(false);
  };

  return (
    <motion.div
      layout
      data-state={isOpen ? "open" : "closed"}
      animate={
        isRejecting && !reducedMotion
          ? { x: [0, -8, 8, -6, 6, 0] }
          : { x: 0 }
      }
      onAnimationComplete={() => {
        if (isRejecting) setIsRejecting(false);
      }}
      onKeyDown={(event) => {
        if (event.key === "Escape" && isOpen) {
          event.preventDefault();
          if (isPanelOpen) collapse();
          else setOpen(false);
        }
      }}
      transition={
        reducedMotion
          ? { duration: 0 }
          : {
              layout: { type: "spring", stiffness: 420, damping: 34 },
              x: { delay: 0.3, duration: 0.38, ease: "easeInOut" },
            }
      }
      className={cn(
        "relative isolate overflow-hidden border border-white/15 bg-black text-white shadow-[0_14px_50px_rgba(0,0,0,0.28)]",
        isPanelOpen
          ? "h-48 w-[min(22rem,calc(100vw-3rem))] rounded-[2rem]"
          : isRejecting || isPillOpen
            ? "h-16 w-52 rounded-full"
            : "h-11 w-32 rounded-full",
        className,
      )}
    >
      <AnimatePresence initial={false} mode="wait">
        {isPanelOpen ? (
          <motion.div
            key="expanded"
            initial={reducedMotion ? false : { opacity: 0, scale: 0.96 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reducedMotion ? undefined : { opacity: 0, scale: 0.96 }}
            transition={{ duration: reducedMotion ? 0 : 0.18 }}
            className="absolute inset-x-0 top-0 bottom-10"
          >
            {expanded}
          </motion.div>
        ) : (
          <motion.button
            ref={triggerRef}
            key="compact"
            type="button"
            aria-expanded={isOpen}
            aria-label={isOpen ? collapseLabel : expandLabel}
            onClick={() => {
              if (isEmpty) {
                if (!reducedMotion) setIsRejecting(true);
                return;
              }
              setOpen(!isOpen);
            }}
            initial={reducedMotion ? false : { opacity: 0, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reducedMotion ? undefined : { opacity: 0, scale: 0.9 }}
            transition={{ duration: reducedMotion ? 0 : 0.15 }}
            className="h-full w-full rounded-full px-2 outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-inset"
          >
            {compact}
          </motion.button>
        )}
      </AnimatePresence>

      {isPanelOpen && (
        <button
          type="button"
          aria-label={collapseLabel}
          onClick={collapse}
          className="group absolute inset-x-0 bottom-0 z-10 flex h-10 items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-inset"
        >
          <span className="h-1 w-9 rounded-full bg-white/25 transition-colors group-hover:bg-white/45" />
        </button>
      )}
    </motion.div>
  );
}

More from spark-ui

Browse all components in this library →