Dynamic Island with Morphing Live Activities

A phone top with a black pill that changes shape for whatever is going on: a small idle pill, a wide strip for an active call, a taller bar for the song that is playing and a full card for a ride that is on its way. The shell springs to each size while the old content blurs away and the new content scales in. The demo plays through the four states on its own, and four buttons let a visitor pick one. On phones the copy stacks above the device.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/dynamic-island)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Car, MessageCircle, Phone, PhoneOff, SkipForward, Pause } from "lucide-react";

type View = "idle" | "call" | "music" | "ride";

/** Size of the island in each state. Width and height are pixels, radius is the corner. */
const SIZES: Record<View, { width: number; height: number; radius: number }> = {
  idle: { width: 118, height: 34, radius: 17 },
  call: { width: 232, height: 40, radius: 20 },
  music: { width: 288, height: 76, radius: 34 },
  ride: { width: 288, height: 184, radius: 38 },
};

const views: { id: View; label: string }[] = [
  { id: "idle", label: "Idle" },
  { id: "call", label: "Call" },
  { id: "music", label: "Music" },
  { id: "ride", label: "Ride" },
];

/** The order the demo plays through on its own, and how long each state stays. */
const AUTOPLAY: View[] = ["music", "ride", "call", "idle"];
const HOLD_MS = 2600;

const SPRING = { type: "spring" as const, stiffness: 400, damping: 30 };

function Equalizer() {
  return (
    <span aria-hidden="true" className="flex h-5 items-center gap-[3px]">
      {[0, 1, 2, 3].map((i) => (
        <span
          key={i}
          className="dynamic-island-with-morphing-live-activities-bar block h-full w-[3px] origin-center rounded-full bg-rose-400 motion-reduce:animate-none"
          style={{ animationDelay: `${i * -0.27}s` }}
        />
      ))}
    </span>
  );
}

function IslandContent({ view }: { view: View }) {
  if (view === "idle") {
    return (
      <div className="flex h-full items-center justify-end pr-3">
        <span className="h-2.5 w-2.5 rounded-full bg-neutral-800 ring-1 ring-neutral-700" />
      </div>
    );
  }
  if (view === "call") {
    return (
      <div className="flex h-full items-center justify-between px-3.5">
        <span className="flex items-center gap-2 text-[13px] font-medium text-white">
          <Phone aria-hidden="true" className="h-3.5 w-3.5 fill-emerald-400 text-emerald-400" />
          Maya Okafor
        </span>
        <span className="text-[13px] font-medium tabular-nums text-emerald-400">0:42</span>
      </div>
    );
  }
  if (view === "music") {
    return (
      <div className="flex h-full items-center gap-3 px-3.5">
        <span aria-hidden="true" className="h-12 w-12 shrink-0 rounded-2xl bg-gradient-to-br from-rose-400 via-orange-300 to-amber-200" />
        <span className="min-w-0 flex-1 text-left">
          <span className="block truncate text-sm font-semibold text-white">Night Ferry</span>
          <span className="block truncate text-xs text-neutral-400">Low Tide Club</span>
        </span>
        <Equalizer />
      </div>
    );
  }
  return (
    <div className="flex h-full flex-col justify-between p-4 text-left">
      <div className="flex items-start justify-between gap-3">
        <div>
          <p className="text-xs font-medium text-neutral-400">Pickup in</p>
          <p className="text-2xl font-semibold tracking-tight text-white">4 min</p>
        </div>
        <span className="rounded-full bg-neutral-800 px-2.5 py-1 text-[11px] font-medium text-neutral-200">Grey Polestar ยท 7KM 204</span>
      </div>
      <div aria-hidden="true" className="relative h-1.5 rounded-full bg-neutral-800">
        <span className="absolute inset-y-0 left-0 w-[62%] rounded-full bg-sky-400" />
        <span className="absolute left-[62%] top-1/2 flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-neutral-900 shadow">
          <Car className="h-3.5 w-3.5" />
        </span>
      </div>
      <div className="flex gap-2">
        <button type="button" className="flex h-10 flex-1 items-center justify-center gap-1.5 rounded-full bg-neutral-800 text-xs font-semibold text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">
          <MessageCircle aria-hidden="true" className="h-3.5 w-3.5" /> Message Idris
        </button>
        <button type="button" className="h-10 rounded-full bg-neutral-800 px-4 text-xs font-semibold text-rose-300 transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">
          Cancel
        </button>
      </div>
    </div>
  );
}

export default function DynamicIslandWithMorphingLiveActivities() {
  const reduce = useReducedMotion();
  const [view, setView] = useState<View>("music");
  const [auto, setAuto] = useState(true);

  // Play through the states until the visitor picks one.
  useEffect(() => {
    if (!auto || reduce) return;
    const id = window.setInterval(() => {
      setView((v) => AUTOPLAY[(AUTOPLAY.indexOf(v) + 1) % AUTOPLAY.length]);
    }, HOLD_MS);
    return () => window.clearInterval(id);
  }, [auto, reduce]);

  const size = SIZES[view];
  const spring = reduce ? { duration: 0 } : SPRING;

  return (
    <section className="bg-stone-100 px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes dynamic-island-with-morphing-live-activities-eq { 0%, 100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
        .dynamic-island-with-morphing-live-activities-bar { animation: dynamic-island-with-morphing-live-activities-eq 0.9s ease-in-out infinite; }
      `}</style>
      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2">
        <div className="text-center lg:text-left">
          <p className="text-sm font-semibold uppercase tracking-widest text-rose-600">Live activities</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">One pill that reshapes itself for whatever is happening.</h2>
          <p className="mt-4 text-base leading-7 text-stone-600">A call, the song that is playing, a ride on its way. The island springs to the size each one needs and blurs the old content out as the new content settles in.</p>
          <div role="group" aria-label="Island state" className="mt-8 inline-flex flex-wrap justify-center gap-2">
            {views.map((v) => (
              <button
                key={v.id}
                type="button"
                aria-pressed={view === v.id}
                onClick={() => {
                  setAuto(false);
                  setView(v.id);
                }}
                className={`h-10 rounded-full px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100 ${
                  view === v.id ? "bg-stone-900 text-white" : "bg-white text-stone-700 ring-1 ring-stone-300 hover:bg-stone-50"
                }`}
              >
                {v.label}
              </button>
            ))}
          </div>
        </div>

        {/* Top half of a phone. The island sits where the camera cut-out would be. */}
        <div className="mx-auto w-full max-w-[340px]">
          <div className="relative h-[420px] overflow-hidden rounded-t-[52px] border-[10px] border-b-0 border-stone-900 bg-gradient-to-b from-sky-200 via-rose-100 to-amber-100 shadow-2xl shadow-stone-900/20">
            <div className="flex items-center justify-between px-7 pt-4 text-[13px] font-semibold text-stone-900">
              <span>9:41</span>
              <span aria-hidden="true" className="flex items-center gap-1">
                <span className="h-2.5 w-4 rounded-[3px] border border-stone-900/70 p-px">
                  <span className="block h-full w-3/4 rounded-[1px] bg-stone-900" />
                </span>
              </span>
            </div>

            <div className="absolute inset-x-0 top-3 flex justify-center">
              <motion.div
                aria-live="polite"
                initial={false}
                animate={{ width: size.width, height: size.height, borderRadius: size.radius }}
                transition={spring}
                className="relative overflow-hidden bg-black shadow-lg shadow-black/30"
              >
                <AnimatePresence initial={false}>
                  <motion.div
                    key={view}
                    initial={reduce ? false : { opacity: 0, scale: 0.9, y: 5 }}
                    animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20, filter: "blur(10px)" }}
                    transition={spring}
                    className="absolute inset-0 flex justify-center"
                  >
                    {/* Each state keeps its own fixed size, so content never reflows while the shell springs. */}
                    <div className="shrink-0" style={{ width: size.width, height: size.height }}>
                      <IslandContent view={view} />
                    </div>
                  </motion.div>
                </AnimatePresence>
              </motion.div>
            </div>

            <div className="absolute inset-x-0 bottom-0 px-6 pb-8 text-center">
              <p className="text-sm font-medium text-stone-700">Thursday 9 October</p>
              <p className="text-7xl font-semibold tracking-tight text-stone-900">9:41</p>
              <div className="mt-6 flex items-center justify-center gap-3 text-stone-800">
                <span className="flex h-11 w-11 items-center justify-center rounded-full bg-white/60"><Pause aria-hidden="true" className="h-4 w-4" /></span>
                <span className="flex h-11 w-11 items-center justify-center rounded-full bg-white/60"><SkipForward aria-hidden="true" className="h-4 w-4" /></span>
                <span className="flex h-11 w-11 items-center justify-center rounded-full bg-white/60"><PhoneOff aria-hidden="true" className="h-4 w-4" /></span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/dynamic-island-with-morphing-live-activities.json

When to use it

How to customize it

Accessibility

Questions

How does the island change size so smoothly?

The black shell is one `motion.div` whose `width`, `height` and `borderRadius` are animated with a spring. Each state's content has its own fixed size and is centred inside the shell, so nothing inside reflows while the shell stretches around it.

Why does the old content blur as it leaves?

The content sits in `AnimatePresence`. The leaving state fades, shrinks a little, drops 20 pixels and blurs, while the new one fades and scales in. The blur hides the moment where two layouts overlap, which is what makes the change read as one object reshaping.

Can I drive it from real events?

Yes. The component holds one `view` value. Call `setView("call")` from your own event (a socket message, a timer, a click) and remove the auto-play effect.

Does it need the phone frame?

No. The island is the `motion.div` with the `bg-black` class and its `AnimatePresence` child. Move those into a fixed header or a toast area and drop the frame.

Will it fit small phones?

The widest state is 288 pixels, which fits inside the frame at a 360 pixel viewport. If you widen a state, check that screen size.

Adapted from Dynamic Island in Cult UI, used under its open-source licence. Licences and credits

Related: Habit Tracker Phone Screen with Tab Bar, App Landing Hero with Phone Mockup, Dark Toast Notification Stack with Undo and Progress.

More app and game ui or all free UI components. Start for free to have a full site built around it.

More in App UI

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap