island
Morphing sticky header for long reads: progress ring, scrollspy section, section menu, and a Read-next end phase.
navigationheaderscrollspyprogress
Preview
Why islands
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
How it morphs
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Winding down
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Scroll to watch the island expand, track this section, and finally morph into a Read-next link. This paragraph only exists to give it something to measure.
Installation
shadcn CLI
npx shadcn@latest add https://mocoui.site/r/island.jsonManual
Copy each file from the Source section into its target path:
registry/island/island.tsx → components/moco/island.tsx
registry/island/island.css → components/moco/island.cssUsage
"use client";
import { Island, type Heading } from "./island";
const HEADINGS: Heading[] = [
{ id: "why", text: "Why islands" },
{ id: "how", text: "How it morphs" },
{ id: "end", text: "Winding down" },
];
export default function IslandDemo() {
return (
<div>
<Island
title="A demo essay about islands"
headings={HEADINGS}
readingTime={4}
brand={{ label: "Home", href: "#" }}
links={[{ label: "All writing", href: "#" }]}
next={{ label: "The follow-up essay", href: "#" }}
articleSelector="#article-root"
/>
<article id="article-root" style={{ maxWidth: "42rem", margin: "0 auto", padding: "6rem 1rem" }}>
{HEADINGS.map((h) => (
<section key={h.id}>
<h2 id={h.id}>{h.text}</h2>
{Array.from({ length: 14 }, (_, i) => (
<p key={i}>
Scroll to watch the island expand, track this section, and finally morph into a
Read-next link. This paragraph only exists to give it something to measure.
</p>
))}
</section>
))}
</article>
</div>
);
}
Source
components/moco/island.tsx
"use client";
import * as React from "react";
import "./island.css";
import {
useReadProgress,
useScrollspy,
useReducedMotion,
smoothScrollTo,
} from "@/components/moco/hooks";
export interface Heading {
id: string;
text: string;
level?: number;
}
export interface IslandLink {
label: string;
href: string;
}
export interface IslandProps {
title: string;
headings: Heading[];
/** Minutes. Shown at the top and counted down by the progress ring. */
readingTime: number;
/** Home / wordmark link shown while reading. */
brand: IslandLink;
/** Extra links listed under "Elsewhere" in the section menu. */
links: IslandLink[];
/** "Read next →" target. The end phase is skipped when absent. */
next?: IslandLink | null;
/** CSS selector for the article element progress is measured against. */
articleSelector?: string;
}
/**
* The morphing header island. Compact at scroll 0; expands to reveal the post
* title, a scrollspy section name and a progress ring as you read; morphs once
* more into "Read next →" at the end.
*/
export function Island({
title,
headings,
readingTime,
brand,
links,
next,
articleSelector = "#article-root",
}: IslandProps) {
const ids = React.useMemo(() => headings.map((h) => h.id), [headings]);
const progress = useReadProgress(articleSelector);
const active = useScrollspy(ids);
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [entered, setEntered] = React.useState(false);
const [width, setWidth] = React.useState<number | undefined>();
const inner = React.useRef<HTMLDivElement>(null);
const shell = React.useRef<HTMLDivElement>(null);
const phase: "top" | "reading" | "end" =
progress <= 0.02 ? "top" : progress >= 0.94 && next ? "end" : "reading";
// Re-measure the content and animate the shell to it.
React.useLayoutEffect(() => {
const el = inner.current;
if (!el) return;
const measure = () => setWidth(el.scrollWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [phase, active, title, next]);
// Slide + fade the swapped text in.
React.useEffect(() => {
if (reduced) {
setEntered(true);
return;
}
setEntered(false);
const t = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(t);
}, [phase, reduced]);
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!shell.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const left = Math.max(1, Math.ceil(readingTime * (1 - progress)));
const section = headings[active]?.text;
return (
<div
ref={shell}
className="moco-island"
style={reduced ? undefined : { width }}
role="navigation"
aria-label="Article navigation"
>
<div ref={inner} className="moco-island-in">
<div className="moco-island-slot" data-enter={entered}>
{phase === "end" && next ? (
<a href={next.href} className="moco-island-title">
Read next → {next.label}
</a>
) : (
<>
<a href={brand.href}>
{brand.label} <span aria-hidden="true">↑</span>
</a>
{phase === "top" ? (
<>
<span className="moco-island-sep" aria-hidden="true">
|
</span>
<span className="moco-island-section">{readingTime} min read</span>
</>
) : (
<>
<span className="moco-island-sep" aria-hidden="true">
|
</span>
<button
type="button"
className="moco-island-title"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((v) => !v)}
>
{title} <span aria-hidden="true">⌄</span>
</button>
{section ? (
<>
<span className="moco-island-sep moco-hide-sm" aria-hidden="true">
›
</span>
<span className="moco-island-section moco-hide-sm">{section}</span>
</>
) : null}
</>
)}
</>
)}
</div>
<Ring progress={progress} label={`${left}m`} />
</div>
{open ? (
<div className="moco-island-menu" role="menu" aria-label="Sections and site links">
<p className="moco-menu-head">In this essay</p>
{headings.map((h, i) => (
<button
key={h.id}
type="button"
role="menuitem"
aria-current={i === active}
onClick={() => {
setOpen(false);
smoothScrollTo(h.id);
}}
>
{h.text}
</button>
))}
{links.length ? (
<>
<hr />
<p className="moco-menu-head">Elsewhere</p>
{links.map((l) => (
<a key={l.href} href={l.href} role="menuitem" onClick={() => setOpen(false)}>
{l.label}
</a>
))}
</>
) : null}
</div>
) : null}
</div>
);
}
function Ring({ progress, label }: { progress: number; label: string }) {
const r = 12;
const c = 2 * Math.PI * r;
return (
<span
className="moco-ring"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
aria-valuetext={`${Math.round(progress * 100)} percent read, about ${label} left`}
>
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true">
<circle cx="15" cy="15" r={r} fill="none" stroke="var(--moco-line)" strokeWidth="1" />
<circle
cx="15"
cy="15"
r={r}
fill="none"
stroke="var(--moco-accent-dk)"
strokeWidth="1.5"
strokeDasharray={c}
strokeDashoffset={c * (1 - progress)}
style={{ transition: "stroke-dashoffset 120ms linear" }}
/>
</svg>
<span className="moco-ring-time">{label}</span>
</span>
);
}
components/moco/island.css
/* Element resets the host app may not provide. */
.moco-island a,
.moco-island button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
text-decoration: none;
font-variant-numeric: tabular-nums;
}
.moco-island {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: flex;
align-items: center;
height: 44px;
border: 1px solid var(--moco-line);
border-radius: 999px;
background: color-mix(in srgb, var(--moco-bg) 78%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
font-size: 13px;
color: var(--moco-muted);
white-space: nowrap;
max-width: calc(100vw - 1.5rem);
transition: width 400ms var(--moco-ease);
overflow: hidden;
}
.moco-island-in {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0 0.85rem;
width: max-content;
height: 100%;
}
.moco-island-slot {
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition:
transform 320ms var(--moco-ease),
opacity 320ms var(--moco-ease);
}
.moco-island-slot[data-enter="false"] {
transform: translateX(32px);
opacity: 0;
}
.moco-island a:hover,
.moco-island button:hover {
color: var(--moco-ink);
}
.moco-island-sep {
color: var(--moco-line);
}
.moco-island-section {
color: var(--moco-faint);
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
}
.moco-island-title {
color: var(--moco-ink);
max-width: 26ch;
overflow: hidden;
text-overflow: ellipsis;
}
.moco-ring {
position: relative;
width: 30px;
height: 30px;
flex: none;
display: grid;
place-items: center;
}
.moco-ring svg {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.moco-ring-time {
font-size: 10.5px;
color: var(--moco-faint);
letter-spacing: -0.02em;
}
.moco-island-menu {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: min(360px, calc(100vw - 1.5rem));
background: var(--moco-bg);
border: 1px solid var(--moco-line);
border-radius: 4px;
padding: 0.4rem;
z-index: 61;
box-shadow: none;
}
.moco-island-menu button,
.moco-island-menu a {
display: block;
width: 100%;
text-align: left;
padding: 0.4rem 0.6rem;
font-size: 13px;
color: var(--moco-muted);
white-space: normal;
border-radius: 2px;
}
.moco-island-menu button:hover,
.moco-island-menu a:hover,
.moco-island-menu button[aria-current="true"] {
color: var(--moco-ink);
background: var(--moco-hover-wash);
}
.moco-island-menu .moco-menu-head {
font-size: 10.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--moco-faint);
padding: 0.6rem 0.6rem 0.25rem;
margin: 0;
}
.moco-island-menu hr {
margin: 0.35rem 0.6rem;
border: 0;
border-top: 1px solid var(--moco-line);
}
@media (max-width: 720px) {
.moco-island {
gap: 0.6rem;
font-size: 12px;
}
.moco-island .moco-hide-sm {
display: none;
}
.moco-island-title {
max-width: 16ch;
}
}