# Article With Scroll-Spy Contents

> Long-form article with a sticky table of contents sidebar that scroll-spies and highlights the active section, collapsing to one column on mobile.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/article-2).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/article-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/article-2-light-5b43b254f107adf5.png
- Registry item: https://7ovr.com/r/article-2.json
- Categories: article
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/article-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`
- Registry items: `avatar`, `badge`

## Files

### `registry/blocks/article/2/article-block.tsx`

```tsx
"use client"

import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

type Section = { id: string; title: string; body: string[] }

const sections: Section[] = [
  {
    id: "overview",
    title: "Overview",
    body: [
      "Design tokens are the single source of truth for the visual language of a product. Instead of hard-coding colors and spacing in components, you reference named values that can change in one place.",
      "This guide walks through how we structure tokens, how they map to themes, and how to adopt them without a disruptive migration.",
    ],
  },
  {
    id: "structure",
    title: "Token structure",
    body: [
      "We split tokens into three tiers: primitives, semantic tokens, and component tokens. Primitives are raw values, semantic tokens describe intent, and component tokens bind intent to a specific part.",
      "Keeping these tiers separate means a rebrand touches primitives only, while component code keeps referring to the same semantic names.",
    ],
  },
  {
    id: "theming",
    title: "Theming",
    body: [
      "Each theme remaps the semantic tier. A dark theme overrides background and foreground tokens, and every component that reads those names updates automatically with no per-component work.",
      "Because the contract is the token name, you can ship a new theme without touching a single component file.",
    ],
  },
  {
    id: "adoption",
    title: "Adoption",
    body: [
      "Roll out tokens incrementally. Start with color, then spacing, then typography, shipping each tier behind the same review process you already use.",
      "Within a few sprints the hard-coded values are gone and every surface speaks the same visual language.",
    ],
  },
]

export default function ArticleBlock() {
  const [active, setActive] = React.useState(sections[0].id)
  const scrollRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    const root = scrollRef.current
    if (!root) return

    const update = () => {
      const rootTop = root.getBoundingClientRect().top
      const threshold = root.clientHeight * 0.3
      let current = sections[0].id
      for (const section of sections) {
        const el = document.getElementById(section.id)
        if (el && el.getBoundingClientRect().top - rootTop <= threshold) {
          current = section.id
        }
      }
      const atBottom =
        root.scrollTop + root.clientHeight >= root.scrollHeight - 48
      setActive(atBottom ? sections[sections.length - 1].id : current)
    }

    update()
    root.addEventListener("scroll", update, { passive: true })
    return () => root.removeEventListener("scroll", update)
  }, [])

  return (
    <section className="flex min-h-svh w-full justify-center bg-background px-6 py-16 text-foreground">
      <div className="mx-auto w-full max-w-4xl">
        <div className="max-w-2xl">
          <Badge variant="secondary" className="mb-4">
            Guide
          </Badge>
          <h1 className="font-heading text-3xl font-bold tracking-tight text-balance">
            A practical guide to design tokens
          </h1>
          <div className="mt-5 flex items-center gap-3">
            <Avatar className="size-9">
              <AvatarImage
                src="https://i.pravatar.cc/80?img=32"
                alt="Priya Nair"
                className="grayscale"
              />
              <AvatarFallback>PN</AvatarFallback>
            </Avatar>
            <div className="flex flex-col">
              <span className="text-sm font-medium">Priya Nair</span>
              <span className="flex items-center gap-3 text-xs text-muted-foreground">
                <span className="tabular-nums">Jun 12, 2026</span>
                <span className="flex items-center gap-1">
                  <IconPlaceholder
                    lucide="Clock"
                    tabler="IconClock"
                    hugeicons="TimeIcon"
                    phosphor="Clock"
                    remixicon="RiTimeLine"
                    className="size-3.5"
                    aria-hidden="true"
                  />
                  8 Min Read
                </span>
              </span>
            </div>
          </div>
        </div>

        <div className="mt-10 flex flex-col gap-10 md:flex-row md:gap-12">
          <div
            ref={scrollRef}
            className="flex min-w-0 flex-1 flex-col gap-10 md:max-h-[26rem] md:overflow-y-auto md:pr-5"
          >
            {sections.map((section) => (
              <section key={section.id} id={section.id} className="scroll-mt-2">
                <h2 className="font-heading text-xl font-semibold tracking-tight">
                  {section.title}
                </h2>
                <div className="mt-3 flex flex-col gap-4 text-[15px]/relaxed text-foreground/80">
                  {section.body.map((paragraph, pIndex) => (
                    <p key={`${section.id}-${pIndex}`}>{paragraph}</p>
                  ))}
                </div>
              </section>
            ))}
          </div>

          <aside className="hidden w-52 shrink-0 md:block">
            <p className="mb-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">
              On This Page
            </p>
            <nav className="flex flex-col border-l border-border">
              {sections.map((section) => (
                <a
                  key={section.id}
                  href={`#${section.id}`}
                  onClick={() => setActive(section.id)}
                  aria-current={active === section.id ? "page" : undefined}
                  className={cn(
                    "-ml-px border-l py-1.5 pl-4 text-sm transition-colors",
                    active === section.id
                      ? "border-primary font-medium text-foreground"
                      : "border-transparent text-muted-foreground hover:text-foreground"
                  )}
                >
                  {section.title}
                </a>
              ))}
            </nav>
          </aside>
        </div>
      </div>
    </section>
  )
}
```
