Skip to content

Navigationv0.1.0

Dynamic Island

A pill that morphs between compact and expanded activities.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dynamic-island.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/dynamic-island/dynamic-island.tsx
    "use client";
    
    import type { ComponentProps, ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration } from "@/lib/motion";
    
    export interface DynamicIslandProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Key of the current activity. Changing it morphs the island to fit the new content. @default "idle" */
      view?: string;
      /** Content of the current activity. Omit it for the idle pill. */
      children?: ReactNode;
      /** Corner radius in px for tall, expanded activities. Short ones always render as a pill. @default 28 */
      radius?: number;
    }
    
    // A slightly underdamped spring gives the island its characteristic "gooey" settle.
    const MORPH = { type: "spring", stiffness: 400, damping: 30, mass: 0.9 } as const;
    
    export function DynamicIsland({ view = "idle", children, radius = 28, className, ...props }: DynamicIslandProps) {
      const reduceMotion = useReducedMotion();
      const transition = reduceMotion ? { duration: 0 } : MORPH;
    
      return (
        <motion.div
          layout
          transition={transition}
          style={{ borderRadius: radius }}
          className={cn(
            "relative mx-auto flex min-h-9 min-w-[7.5rem] items-center justify-center overflow-hidden bg-zinc-950 text-white shadow-[0_8px_30px_rgb(0_0_0/0.18)]",
            className,
          )}
          {...props}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={view}
              layout="position"
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(6px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={
                reduceMotion
                  ? { opacity: 0, transition: { duration: duration.instant } }
                  : { opacity: 0, scale: 0.92, filter: "blur(6px)", transition: { duration: duration.quick } }
              }
              transition={{ duration: duration.base, ease: [0.22, 1, 0.36, 1], delay: reduceMotion ? 0 : 0.04, layout: transition }}
              className="w-full"
            >
              {children ?? <span aria-hidden="true" className="block h-9 w-[7.5rem]" />}
            </motion.div>
          </AnimatePresence>
        </motion.div>
      );
    }

Usage

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

export function LiveActivity({ upload }: { upload?: number }) {
  return (
    <DynamicIsland view={upload === undefined ? "idle" : "upload"}>
      {upload === undefined ? undefined : <UploadProgress value={upload} />}
    </DynamicIsland>
  );
}

API reference

PropTypeDefaultDescription
viewstring"idle"Key of the current activity. Changing it morphs the island to fit the new content.
childrenReactNode–Content of the current activity. Omit it for the idle pill.
radiusnumber28Corner radius in px for tall, expanded activities. Short ones always render as a pill.

Also accepts every native <div> attribute, including ref and className.

  • Change view to move between activities. The island's size follows its content through a Motion layout animation, and the radius is corrected mid-morph.
  • Pass a larger radius for tall, expanded activities; short ones stay pill-shaped.

Accessibility

  • The island is a plain container; give it role="status" and a label when its content is a live update, as in the example.
  • Controls inside expanded activities are ordinary buttons, reachable by keyboard.
  • With reduced motion enabled, the island resizes instantly and its content cross-fades without blur or scale.