Documentation menu

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

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco island component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/island.json

Manual

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.css

Needs moco items: tokens, hooks

Usage

usage.tsx
"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;
  }
}