Island
moco/islandFreeComponent
Morphing sticky header for long reads: progress ring, scrollspy section, section menu, and a Read-next end phase.
Live preview
live · rendered by the registry
Rendered by moco on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://mocoui.site/r/island.jsonSource
1"use client";23import * as React from "react";4import "./island.css";5import {6 useReadProgress,7 useScrollspy,8 useReducedMotion,9 smoothScrollTo,10} from "@/components/moco/hooks";1112export interface Heading {13 id: string;14 text: string;15 level?: number;16}1718export interface IslandLink {19 label: string;20 href: string;21}2223export interface IslandProps {24 title: string;25 headings: Heading[];26 /** Minutes. Shown at the top and counted down by the progress ring. */27 readingTime: number;28 /** Home / wordmark link shown while reading. */29 brand: IslandLink;30 /** Extra links listed under "Elsewhere" in the section menu. */31 links: IslandLink[];32 /** "Read next →" target. The end phase is skipped when absent. */33 next?: IslandLink | null;34 /** CSS selector for the article element progress is measured against. */35 articleSelector?: string;36}3738/**39 * The morphing header island. Compact at scroll 0; expands to reveal the post40 * title, a scrollspy section name and a progress ring as you read; morphs once41 * more into "Read next →" at the end.42 */43export function Island({44 title,45 headings,46 readingTime,47 brand,48 links,49 next,50 articleSelector = "#article-root",51}: IslandProps) {52 const ids = React.useMemo(() => headings.map((h) => h.id), [headings]);53 const progress = useReadProgress(articleSelector);54 const active = useScrollspy(ids);55 const reduced = useReducedMotion();5657 const [open, setOpen] = React.useState(false);58 const [entered, setEntered] = React.useState(false);59 const [width, setWidth] = React.useState<number | undefined>();60 const inner = React.useRef<HTMLDivElement>(null);61 const shell = React.useRef<HTMLDivElement>(null);6263 const phase: "top" | "reading" | "end" =64 progress <= 0.02 ? "top" : progress >= 0.94 && next ? "end" : "reading";6566 // Re-measure the content and animate the shell to it.67 React.useLayoutEffect(() => {68 const el = inner.current;69 if (!el) return;70 const measure = () => setWidth(el.scrollWidth);71 measure();72 const ro = new ResizeObserver(measure);73 ro.observe(el);74 return () => ro.disconnect();75 }, [phase, active, title, next]);7677 // Slide + fade the swapped text in.78 React.useEffect(() => {79 if (reduced) {80 setEntered(true);81 return;82 }83 setEntered(false);84 const t = requestAnimationFrame(() => setEntered(true));85 return () => cancelAnimationFrame(t);86 }, [phase, reduced]);8788 React.useEffect(() => {89 if (!open) return;90// … truncated
What it pulls in
Other registry items
Files it writes
More from moco
All 20 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Codecode | moco | Components | Free | 1 dep · 3 files | |
| Comparecompare | moco | Components | Free | no deps · 2 files | |
| Count Upcountup | moco | Components | Free | no deps · 2 files | |
| Covercover | moco | Components | Free | no deps · 2 files | |
| Diagram Kitdiagram | moco | Components | Free | no deps · 2 files | |
| Dot Griddotgrid | moco | Components | Free | no deps · 2 files | |
| Figurefigure | moco | Components | Free | no deps · 2 files | |
| Hookshooks | moco | Components | Free | no deps |
