Morphing Island Notch
A Dynamic-Island-style status pill with no dependencies: it measures its own content, springs to size, and fuses a stiff and a soft body through a goo filter so the silhouette stretches like liquid. Content crossfades between states, with room for live controls like call buttons and a waveform.
DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
/*
* A Dynamic-Island-style status pill with no dependencies.
*
* No width or height is ever specified. The content row is measured with a
* ResizeObserver and the shell is driven to that size by a small spring loop
* (semi-implicit Euler, written straight to the DOM). Behind the shell two more
* bodies chase the same target, one stiffer and one softer, and an SVG "goo"
* filter fuses them, so the silhouette stretches and snaps like liquid.
* The corner radius is derived from the live height every frame, so it never skews.
*/
type Snap = {
key: string;
icon: ReactNode;
label: string | null | undefined;
detail: string | null | undefined;
trailing: ReactNode;
};
const PAD = 24; // how far the goo layer extends past the shell on every side
const MAX_RADIUS = 28;
const CSS = [
".mi-in{animation:mi-in 340ms cubic-bezier(.22,1,.36,1) 70ms both}",
".mi-out{animation:mi-out 200ms cubic-bezier(.4,0,1,1) both}",
"@keyframes mi-in{from{opacity:0;filter:blur(6px);transform:scale(.94)}to{opacity:1;filter:blur(0);transform:scale(1)}}",
"@keyframes mi-out{from{opacity:1;filter:blur(0);transform:scale(1)}to{opacity:0;filter:blur(6px);transform:scale(.94)}}",
".mi-glow{opacity:0;transition:opacity 260ms ease}",
".mi-root:hover .mi-glow{opacity:1}",
"@media (prefers-reduced-motion:reduce){.mi-in{animation:mi-fade 200ms linear both}.mi-out{animation:mi-fade-out 120ms linear both}.mi-glow{display:none}}",
"@keyframes mi-fade{from{opacity:0}to{opacity:1}}",
"@keyframes mi-fade-out{from{opacity:1}to{opacity:0}}",
].join("\n");
const markInert = (el: HTMLDivElement | null) => {
if (el) el.toggleAttribute("inert", true);
};
const nowMs = () => performance.now();
type Spring = { x: number; v: number };
const spring = (): Spring => ({ x: 0, v: 0 });
function Row({ snap }: { snap: Snap }) {
const expanded = snap.label != null;
return (
<div className="flex items-center gap-2.5 p-2">
<span className="flex size-7 min-h-7 min-w-7 shrink-0 items-center justify-center">{snap.icon}</span>
{expanded ? (
<div className="flex min-w-0 flex-col pr-1 leading-tight" aria-hidden="true">
<span
className="max-w-[15rem] truncate whitespace-nowrap uppercase opacity-55"
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 9.5,
letterSpacing: "0.15em",
}}
>
{snap.label}
</span>
{snap.detail != null ? (
<span className="max-w-[15rem] truncate whitespace-nowrap text-[13.5px] font-semibold tabular-nums">
{snap.detail}
</span>
) : null}
</div>
) : null}
{expanded && snap.trailing ? <span className="flex shrink-0 items-center pl-1">{snap.trailing}</span> : null}
</div>
);
}
export function MorphingIslandNotch({
icon,
label,
detail,
stiffness = 420,
dark = true,
trailing,
stateKey,
idleWidth = 0,
className,
}: {
icon: ReactNode;
/** When label is null/undefined the island collapses to its compact pill. */
label?: string | null;
detail?: string | null;
/** Spring stiffness of the shell (the goo bodies derive theirs from it). */
stiffness?: number;
/** Dark island on a light page, or the inverse. */
dark?: boolean;
/** Optional content on the right of the expanded island (controls, a waveform). */
trailing?: ReactNode;
/**
* Content crossfades when this changes. Defaults to label + detail, so pass one when
* the text updates live (a timer) and should change in place instead of crossfading.
*/
stateKey?: string | number;
/** Minimum width of the collapsed pill in px, for a wider resting notch. */
idleWidth?: number;
className?: string;
}) {
const expanded = label != null;
const key = stateKey != null ? "k" + String(stateKey) : (expanded ? "1" : "0") + (label ?? "") + "\u0001" + (detail ?? "");
const cur: Snap = { key, icon, label, detail, trailing };
// Keep the outgoing content on screen while it fades, derived during render.
const [shown, setShown] = useState<Snap>(cur);
const [prev, setPrev] = useState<Snap | null>(null);
if (shown.key !== key) {
setPrev(shown);
setShown(cur);
}
useEffect(() => {
if (!prev) return;
const t = window.setTimeout(() => setPrev(null), 460);
return () => window.clearTimeout(t);
}, [prev]);
const fid = "mi" + useId().replace(/[^a-zA-Z0-9]/g, "");
const shellRef = useRef<HTMLDivElement>(null);
const measRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
const fastRef = useRef<HTMLDivElement>(null);
const slowRef = useRef<HTMLDivElement>(null);
const rimRef = useRef<HTMLDivElement>(null);
const cfg = useRef({ stiffness, idleWidth, expanded });
const wakeRef = useRef<(() => void) | null>(null);
useEffect(() => {
const shell = shellRef.current;
const meas = measRef.current;
const main = mainRef.current;
const fast = fastRef.current;
const slow = slowRef.current;
const rim = rimRef.current;
if (!shell || !meas || !main || !fast || !slow || !rim) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const w = spring();
const h = spring();
const fw = spring();
const fh = spring();
const sw = spring();
const sh = spring();
const all = [w, h, fw, fh, sw, sh];
const tgt = { w: 0, h: 0 };
let ready = false;
let raf = 0;
let last = 0;
const place = (el: HTMLDivElement, bw: number, bh: number) => {
const ww = Math.max(bw, 2);
const hh = Math.max(bh, 2);
el.style.width = ww + "px";
el.style.height = hh + "px";
el.style.left = PAD + (w.x - ww) / 2 + "px";
el.style.top = PAD + (h.x - hh) / 2 + "px";
el.style.borderRadius = Math.min(hh / 2, MAX_RADIUS) + "px";
};
const apply = () => {
const ww = Math.max(w.x, 2);
const hh = Math.max(h.x, 2);
const r = Math.min(hh / 2, MAX_RADIUS) + "px";
shell.style.width = ww + "px";
shell.style.height = hh + "px";
shell.style.borderRadius = r;
rim.style.borderRadius = r;
main.style.borderRadius = r;
place(fast, fw.x, fh.x);
place(slow, sw.x, sh.x);
};
const snap = () => {
w.x = fw.x = sw.x = tgt.w;
h.x = fh.x = sh.x = tgt.h;
for (const s of all) s.v = 0;
apply();
};
const integrate = (s: Spring, to: number, k: number, zeta: number, dt: number) => {
const m = 0.9;
const c = 2 * zeta * Math.sqrt(k * m);
s.v += ((-k * (s.x - to) - c * s.v) / m) * dt;
s.x += s.v * dt;
};
const loop = (t: number) => {
raf = 0;
let rest = Math.min(0.05, Math.max(0, (t - last) / 1000));
last = t;
const k = cfg.current.stiffness;
while (rest > 1e-6) {
const dt = Math.min(rest, 1 / 120);
rest -= dt;
integrate(w, tgt.w, k, 0.874, dt);
integrate(h, tgt.h, k, 0.874, dt);
integrate(fw, tgt.w, k * 2.1, 0.6, dt);
integrate(fh, tgt.h, k * 2.1, 0.6, dt);
integrate(sw, tgt.w, k * 0.3, 0.82, dt);
integrate(sh, tgt.h, k * 0.3, 0.82, dt);
}
const targets = [tgt.w, tgt.h, tgt.w, tgt.h, tgt.w, tgt.h];
const settled = all.every((s, i) => Math.abs(s.x - (targets[i] ?? 0)) < 0.08 && Math.abs(s.v) < 0.08);
if (settled) {
snap();
return;
}
apply();
raf = requestAnimationFrame(loop);
};
const wake = () => {
const c = cfg.current;
tgt.w = Math.max(meas.offsetWidth, c.expanded ? 0 : c.idleWidth);
tgt.h = meas.offsetHeight;
if (tgt.w === 0 || tgt.h === 0) return;
if (!ready || mq.matches) {
ready = true;
snap();
return;
}
if (!raf) {
last = nowMs();
raf = requestAnimationFrame(loop);
}
};
wakeRef.current = wake;
const ro = new ResizeObserver(wake);
ro.observe(meas);
mq.addEventListener("change", wake);
wake();
return () => {
wakeRef.current = null;
ro.disconnect();
mq.removeEventListener("change", wake);
cancelAnimationFrame(raf);
};
}, []);
useEffect(() => {
cfg.current = { stiffness, idleWidth, expanded };
wakeRef.current?.();
}, [stiffness, idleWidth, expanded]);
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const shell = shellRef.current;
if (!shell) return;
const r = shell.getBoundingClientRect();
const k = r.width > 0 ? shell.offsetWidth / r.width : 1;
shell.style.setProperty("--mi-x", (e.clientX - r.left) * k + "px");
shell.style.setProperty("--mi-y", (e.clientY - r.top) * k + "px");
};
const bg = dark ? "#000" : "#fff";
const fg = dark ? "#fff" : "#0a0c10";
const rimShadow = dark
? "inset 0 0 0 1px rgba(255,255,255,0.1), inset 0 1px 0 rgba(255,255,255,0.14), 0 16px 34px -14px rgba(0,0,0,0.65), 0 2px 6px rgba(0,0,0,0.3)"
: "inset 0 0 0 1px rgba(10,12,16,0.09), 0 16px 34px -14px rgba(10,12,16,0.3), 0 2px 6px rgba(10,12,16,0.08)";
const glow = dark ? "rgba(255,255,255,0.11)" : "rgba(10,12,16,0.06)";
const animateIn = prev !== null;
return (
<div
className={"mi-root relative inline-flex max-w-full align-middle" + (className ? " " + className : "")}
onPointerMove={onMove}
>
<style>{CSS}</style>
<svg width="0" height="0" aria-hidden="true" focusable="false" style={{ position: "absolute" }}>
<filter id={fid} x="-25%" y="-90%" width="150%" height="280%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="b" />
<feColorMatrix in="b" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9" />
</filter>
</svg>
<div aria-hidden="true" className="pointer-events-none absolute" style={{ inset: -PAD, filter: "url(#" + fid + ")" }}>
<div ref={mainRef} className="absolute" style={{ inset: PAD, background: bg }} />
<div ref={fastRef} className="absolute" style={{ background: bg }} />
<div ref={slowRef} className="absolute" style={{ background: bg }} />
</div>
<div
ref={shellRef}
className="relative flex items-center justify-center overflow-hidden"
style={{ background: bg, color: fg }}
>
<div
aria-hidden="true"
className="mi-glow pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(130px circle at var(--mi-x, 50%) var(--mi-y, 50%), " + glow + ", transparent 70%)",
}}
/>
{prev ? (
<div
key={"p" + prev.key}
aria-hidden="true"
ref={markInert}
className="mi-out pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="w-max">
<Row snap={prev} />
</div>
</div>
) : null}
<div ref={measRef} className="relative w-max">
<div key={key} className={animateIn ? "mi-in" : undefined}>
<Row snap={cur} />
</div>
</div>
</div>
<div
ref={rimRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ boxShadow: rimShadow }}
/>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{expanded ? label + (detail ? ": " + detail : "") : ""}
</span>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A status pill that never has a width or height written down. A ResizeObserver measures the content row, and a small spring loop (stiffness 420, damping ratio 0.87 by default) drives the shell to that size, so a longer track title or a translated string just works. Behind the shell, two more bodies chase the same size, one stiffer and overshooting, one softer and trailing, and an SVG filter (blur, then an alpha threshold) fuses them, so the silhouette bulges and snaps back like a drop of liquid while the shell on top stays crisp. The corner radius is computed from the live height every frame, so it is a true pill when short and a 28px rounded rectangle when tall, with no skew. When the state changes the old content blurs out as the new content scales and un-blurs in; pass stateKey to change text in place instead (a timer ticking) without a crossfade. The existing props still work: icon, label, detail, stiffness and dark. New optional props: trailing for live controls on the right (call buttons, a waveform), stateKey, idleWidth for a wider resting notch, and className. A soft highlight follows the pointer across the surface.
Curator’s note
Rebuilt in October without Framer Motion: the props are unchanged, but the island now springs on its own loop and stretches like liquid through a goo filter.


