Page minimap
A page long enough to be read in sections — a form with a dozen field groups, a settings page — can carry a PageMinimap: a thin column of ticks in the gutter, one per section. It marks the reading position, shows each section's status, and expands on hover or keyboard focus into a list you can jump from.
It is not a sidebar. It costs no layout width at rest, stays out of the way until you look for it, and takes itself off the page entirely when the page is too short or too simple to need it.
Reporting data
Building
Building — field 1Filler content so the body is long enough to scroll.
Building — field 2Filler content so the body is long enough to scroll.
Building — field 3Filler content so the body is long enough to scroll.
Floor areas
Floor areas — field 1Filler content so the body is long enough to scroll.
Floor areas — field 2Filler content so the body is long enough to scroll.
Floor areas — field 3Filler content so the body is long enough to scroll.
Ownership
Ownership — field 1Filler content so the body is long enough to scroll.
Ownership — field 2Filler content so the body is long enough to scroll.
Ownership — field 3Filler content so the body is long enough to scroll.
Energy
Energy — field 1Filler content so the body is long enough to scroll.
Energy — field 2Filler content so the body is long enough to scroll.
Energy — field 3Filler content so the body is long enough to scroll.
Certifications
Certifications — field 1Filler content so the body is long enough to scroll.
Certifications — field 2Filler content so the body is long enough to scroll.
Certifications — field 3Filler content so the body is long enough to scroll.
Carbon offsets
Carbon offsets — field 1Filler content so the body is long enough to scroll.
Carbon offsets — field 2Filler content so the body is long enough to scroll.
Carbon offsets — field 3Filler content so the body is long enough to scroll.
Scroll the panel and the ticks follow you. Hover the gutter on the left to open the list, and click a section to jump to it.
The demo is squeezed into the docs column, so it makes two concessions a real page doesn't need: show-from="md", because at the 4xl default the minimap would correctly hide itself at this width, and a plain pl-12 on the sections instead of a centred FocusPanel, because there isn't enough width left to centre and still clear the gutter.
Anatomy
Drop it as the first child of the PageBody whose sections it maps, and give that body the container prop.
vue
<PageBody container>
<PageMinimap v-model:active="activeSection" :items="items" @select="…" />
<FocusPanel size="lg">
<FormSection ref="general" id="general" :title="…">…</FormSection>
<FormSection ref="location" id="location" :title="…">…</FormSection>
</FocusPanel>
</PageBody>ts
const general = useTemplateRef('general') // { el: HTMLElement | null } — what FormSection exposes
const location = useTemplateRef('location')
const items = computed<PageMinimapItem[]>(() => [
{ id: 'general', label: translate('…'), statuses: statusesOf(generalFields), el: general.value?.el },
{ id: 'location', label: translate('…'), statuses: statusesOf(locationFields), el: location.value?.el },
])Each item carries its section's element. That element is what the minimap measures to decide which section holds the reading position, and what it scrolls to when you pick one. A FormSection exposes its <section> as el; a ref on a plain element is the element itself. Pass no elements at all and it becomes a purely controlled list: active is whatever you set, and select is yours to act on.
Declare the sections; don't read them off the DOM. The page knows its sections — their ids, titles and fields are written in its template — so the items are written next to it, one line per section, and a template ref hands over each element. Scanning the rendered form for section[id] and heading text looks shorter, but it makes the minimap depend on markup details (which heading tag, which attribute), labels an untitled section by its id, and hides which sections a page has from anyone reading its script.
Sections rendered from data (a v-for) use a function ref instead; register those elements idempotently. A function ref re-runs on every patch of its element, so a registry that writes a new object on every call makes the section's own render effect depend on the registry and mutate it in the same pass — which Vue stops as a recursive update, taking the rest of that flush down with it. Return early when the registry already agrees:
ts
function setSectionEl(id: string, el: HTMLElement | null) {
if (sectionEls.value[id] === el) return
sectionEls.value = { ...sectionEls.value, [id]: el }
}The content has to leave it a gutter
The minimap takes no layout space — it's a zero-height sticky element with an absolutely positioned body — so it floats over whatever sits at the left edge of PageBody. Full-width sections run straight underneath it.
Two ways to give it room, depending on how much width there is:
| When | |
|---|---|
FocusPanel around the sections | The usual one. A real page is wide enough that centring the content leaves a gutter on both sides. |
pl-12 on the sections | A narrow column, where centring would leave too little on each side to clear the ticks. |
Only the resting ticks need the gutter. The expanded list is an overlay and is meant to sit over the content.
It hides itself when it has nothing to do
Three conditions, all measured live, so a page that grows into needing a minimap gets one with no work from the caller:
| Condition | Why |
|---|---|
| Fewer than two sections | Nothing to navigate between |
| The body fits without scrolling | Nowhere to jump to |
The column is narrower than showFrom | No room beside the content |
The width test is a container query against PageBody container, not the viewport — so a page rendered inside a narrow panel on a wide monitor hides the minimap correctly, which a lg: viewport check gets wrong.
Clicking a section is a fixed-length glide
Native behavior: 'smooth' scales its duration with distance, so on a long page a jump to the last section crawls. The minimap animates the scroll itself at a fixed 260ms, which makes a jump to the last section feel the same length as a jump to the second. A reader who has asked for reduced motion is taken straight there with no animation.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | PageMinimapItem[] | — | Sections in page order: { id, label, statuses?, el? }. |
active | string | — | Id of the section holding the reading position. Use v-model:active. |
showFrom | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '4xl' | Container width from which it appears — md is 28rem, 4xl is 56rem. The low end is for narrow embeddings; a real page wants the default. |
label | string | "Sections" | Accessible name for the nav. |
Emits
| Event | Payload | Description |
|---|---|---|
update:active | string | The scroll position moved into another section. |
select | string | A section was picked from the expanded list. The minimap also scrolls to it when the item carries an el. |
Rules
- Put it first inside
PageBody, and give that bodycontainer. The container is what the width test measures; without it the minimap never appears. - Leave the content a gutter. A
FocusPanelaround the sections on a normal page;pl-12when the column is too narrow to centre. - Pass already-translated labels. Aurora renders
labelas-is — translation keys stay on the page side. - Pass one status dot per level present, not one per issue. The minimap says whether; the section says how many.
- Don't use it as a table of contents for a short page. It hides itself below two sections anyway.
- Don't write the element registry without an equality guard. It causes a recursive-update error that strands the rest of the render flush.
- Don't reach for it when the page already has a
PageSidebar. Two navigations for the same content compete.
Related
- Page structure —
PageBody,FocusPaneland the column it lives in - Indicator — the status dots it renders beside each tick