Skip to content

A live-activity pill that springs open into controls and morphs between activities.

01Preview

Uploading q3-forecast.xlsx, 18%

Finance · Shared

  • Board decks12 files
  • q2-actuals.xlsx2.3 MB · Jul 8
  • revenue-chart.png840 KB · Aug 21
  • vendor-contracts.pdf11.4 MB · Sep 2
  • headcount-plan.xlsx1.1 MB · Sep 14

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { DynamicIsland, DynamicIslandRing } from "@/components/ui/dynamic-island";

<DynamicIsland
  activity="upload"
  label={`Uploading q3-forecast.xlsx, ${pct}%`}
  compact={
    <>
      <DynamicIslandRing value={pct / 100} />
      <span className="pr-1.5 text-[13px] font-medium tabular">{pct}%</span>
    </>
  }
  detail={<UploadDetail onCancel={cancel} />}
/>

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type DynamicIslandProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Names the live activity. When it changes, the island morphs to the new content. */
  activity?: string;
  /** The resting pill: usually a leading glyph and a trailing value. Nothing renders while it is null. */
  compact: React.ReactNode;
  /** What pressing the island reveals: detail and controls. Leave it out and the island is display-only. */
  detail?: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The accessible name, e.g. "Upload in progress". Announced politely when the activity changes. */
  label: string;
  /** absolute sits at the top center of a positioned parent; fixed pins it to the viewport; static leaves it in flow. */
  position?: "absolute" | "fixed" | "static";
};

// The island is always dark, in both themes, like the hardware it's named after.
// It re-scopes the tokens to the dark set, so everything inside uses them as usual.
export function DynamicIsland({
  activity = "default",
  compact,
  detail,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  label,
  position = "absolute",
  className,
  ...rest
}: DynamicIslandProps) {
  const reduce = useReducedMotion();
  const [openState, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
  const expandable = detail != null;
  const open = openState && expandable && compact != null;
  const id = useId();
  const island = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const inner = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);
  const view = `${activity}:${open ? "open" : "compact"}`;
  const present = compact != null;

  // Announce a new activity once, not every time its label ticks ("24:12", "24:11"…).
  const [spoken, setSpoken] = useState(present ? label : "");
  const [lastActivity, setLastActivity] = useState(present ? activity : null);
  const current = present ? activity : null;
  if (current !== lastActivity) {
    setLastActivity(current);
    setSpoken(present ? label : "");
  }

  // Measure whatever is showing; the shell springs to it instead of snapping.
  useLayoutEffect(() => {
    const el = inner.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setSize({ w: el.offsetWidth, h: el.offsetHeight }));
    ro.observe(el);
    return () => ro.disconnect();
  }, [view, present]);

  // Opened: take focus so Escape and Tab work inside. Closed by keyboard: hand focus back to the pill.
  const wasOpen = useRef(open);
  useEffect(() => {
    if (open && !wasOpen.current) panel.current?.focus({ preventScroll: true });
    if (!open && wasOpen.current && island.current?.contains(document.activeElement)) trigger.current?.focus({ preventScroll: true });
    wasOpen.current = open;
  }, [open]);

  // A press anywhere else puts it back.
  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!island.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open, setOpen]);

  const radius = size ? (open ? 26 : size.h / 2) : 999;
  const shell = reduce ? { duration: 0 } : open ? spring.bouncy : spring.snappy;

  return (
    <div
      data-slot="dynamic-island"
      className={cn(
        "flex justify-center @container",
        position === "static" ? "relative w-full" : cn("pointer-events-none inset-x-3 top-3 z-(--z-popover)", position),
        className,
      )}
      {...rest}
    >
      <span role="status" aria-live="polite" className="sr-only">
        {spoken}
      </span>
      {/* An activity already running at page load is just there; only later arrivals animate in. */}
      <AnimatePresence initial={false}>
        {compact != null && (
          <motion.div
            key="island"
            ref={island}
            data-theme="dark"
            data-state={open ? "open" : "compact"}
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)", width: size?.w ?? "auto", height: size?.h ?? "auto", borderRadius: radius }}
            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.6, filter: "blur(4px)", transition: { duration: 0.2, ease: ease.in } }}
            transition={{ ...(reduce ? { duration: 0.15 } : spring.snappy), width: shell, height: shell, borderRadius: shell }}
            onKeyDown={(e) => {
              if (e.key === "Escape" && open) {
                e.stopPropagation();
                setOpen(false);
              }
            }}
            className="pointer-events-auto relative max-w-full overflow-hidden border border-line-2 bg-page text-fg shadow-pop [color-scheme:dark]"
          >
            <div ref={inner} className="w-max max-w-[calc(100cqw-2px)]">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.div
                  key={view}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(4px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: reduce ? 0.12 : 0.26, ease: ease.out, delay: reduce ? 0 : 0.04 } }}
                  exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.94, filter: "blur(4px)", transition: { duration: 0.12, ease: ease.in } }}
                >
                  {open ? (
                    <div
                      ref={panel}
                      id={id}
                      role="group"
                      aria-label={label}
                      tabIndex={-1}
                      className="w-[340px] max-w-[calc(100cqw-2px)] p-4 outline-none"
                    >
                      {detail}
                    </div>
                  ) : expandable ? (
                    <motion.button
                      ref={trigger}
                      type="button"
                      aria-expanded={false}
                      aria-label={label}
                      onClick={() => setOpen(true)}
                      whileTap={{ scale: reduce ? 1 : 0.96 }}
                      transition={spring.snappy}
                      className="flex h-9 min-w-[128px] items-center justify-between gap-3 px-2 text-left outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-3 focus-visible:outline-fg-3 rounded-full"
                    >
                      {compact}
                    </motion.button>
                  ) : (
                    <div role="img" aria-label={label} className="flex h-9 min-w-[128px] items-center justify-between gap-3 px-2">
                      {compact}
                    </div>
                  )}
                </motion.div>
              </AnimatePresence>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

export type DynamicIslandRingProps = Omit<React.ComponentProps<"svg">, "children"> & {
  /** 0 to 1. */
  value: number;
  size?: number;
  /** A color class for the arc, e.g. text-success. Defaults to the foreground. */
  tone?: string;
  children?: React.ReactNode;
};

/** A small progress ring for the compact slots: a timer draining, an upload filling. */
export function DynamicIslandRing({ value, size = 20, tone = "text-fg", className, children, ...rest }: DynamicIslandRingProps) {
  const reduce = useReducedMotion();
  const r = (size - 3) / 2;
  const c = 2 * Math.PI * r;
  const v = Math.min(1, Math.max(0, value));
  return (
    <span className={cn("relative grid shrink-0 place-items-center", className)} style={{ width: size, height: size }}>
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden {...rest}>
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={2} className="text-line-2" />
        <motion.circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          strokeLinecap="round"
          strokeDasharray={c}
          className={tone}
          initial={false}
          animate={{ strokeDashoffset: c * (1 - v) }}
          transition={reduce ? { duration: 0 } : { duration: 0.5, ease: ease.out }}
        />
      </svg>
      {children && <span className="absolute inset-0 grid place-items-center">{children}</span>}
    </span>
  );
}

05Props

DynamicIsland

PropTypeDefaultDescription
compact*React.ReactNodeThe resting pill, laid out leading and trailing. null removes the island.
detailReact.ReactNodeWhat pressing reveals. Without it the island is display-only.
activitystring"default"Which live activity this is. Changing it morphs the island and announces the new label once.
label*stringThe accessible name. Keep the live value in it; it is not re-announced on every tick.
openbooleanControlled expanded state, with defaultOpen and onOpenChange.
position"absolute" | "fixed" | "static""absolute"Top center, 12px from the edge, of a positioned parent or the viewport; or in flow.

DynamicIslandRing

PropTypeDefaultDescription
value*numberProgress from 0 to 1. The arc eases to each new value.
sizenumber20Diameter in px.
tonestring"text-fg"A text color class for the arc, e.g. text-success when done.
childrenReact.ReactNodeA glyph centered inside the ring.

06Notes

Behavior

  • It is always dark, in both themes: the island re-scopes the color tokens to the dark set, so content inside uses the usual classes and semantic colors stay legible.
  • The shell measures whatever is showing and springs to that width, height and radius, so any content fits without hard-coded sizes, up to the container's width.
  • Open, it closes on Escape or a press anywhere outside, and keyboard focus goes back to the pill. If a control inside ends the activity, move focus somewhere sensible yourself, as the demo does.
  • An activity already running at page load is simply there; only activities that start later animate in.

Motion

  • Opening uses the bouncy spring (420/20) for a pixel or two of overshoot, the one delight moment here; closing and switching activity use the snappy spring (520/38). The radius goes from half the height to 26px.
  • Content crossfades in place: the new view scales from 0.92 and unblurs 4px over 260ms after a 40ms beat, while the old one leaves in 120ms.
  • Pressing the pill scales it to 0.96. Reduced motion snaps the size and keeps 100–150ms opacity fades.

Accessibility

  • The pill is a button named by label, with aria-expanded. Open, the detail is a labeled group that takes focus, so Tab reaches its controls at once.
  • A polite status region announces each new activity once; values that tick every second stay in the label without being spoken.
  • Without detail, the pill is role="img" with the label as its name.