Skip to content

feat(pages): mark the Link to the current page - #687

Merged
cpojer merged 2 commits into
voidzero-dev:mainfrom
yangshun:feat/link-active-state
Nov 4, 2026
Merged

cpojer merged 2 commits into
voidzero-dev:mainfrom
yangshun:feat/link-active-state

Conversation

@yangshun

@yangshun yangshun commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #676. Implements the proposal in the issue.

What this adds

Link marks the link to the current page with aria-current="page" and data-void-active, in React, Vue, Svelte and Solid. Navigation menus no longer need to compare useRouter().path with each href by hand, and the current link gets aria-current by default.

<nav>
  <Link href="/">Home</Link>
  <Link href="/users" activeMatch="prefix">Users</Link>
</nav>
nav a[data-void-active] { font-weight: 600; }
  • One new prop: activeMatch?: 'exact' | 'prefix', default 'exact'. prefix keeps a link active on nested pages.
  • Two attributes: data-void-active on any match, aria-current="page" on an exact match only. On /users/42, the Users link above gets data-void-active but not aria-current.
  • Overrides: an aria-current or data-void-active passed to Link replaces the computed value.

Matching rules

Rule Behavior
Path Compared after resolving the href against the current URL, decoded the way the server matches pages
Prefix Whole segments only: /users matches /users/42, not /users-admin. / only matches itself
Letter case, trailing slash Both count, like the router: /Admin and /admin/ don't match /admin
Query and hash Ignored on both the link and the current URL
Other origins, non-GET links Never active

The two open questions from the issue are settled to follow the router, which treats /admin/ and /Admin as different pages from /admin.

Implementation

linkActiveAttributes(router, link, match) in void/pages-client does the matching and returns the attributes, so the names, values and default can't drift between adapters. Each adapter passes its router (which changes with the route URL), spreads the result before the user's attributes, and passes activeMatch through without its own default.

The server-side router already carries the request URL, so the attributes are in the server HTML and hydration doesn't change them. Island pages have no Void Router and render Link without active state; the islands guide already says to use plain <a> there.

Docs

  • Pages guide (docs/guide/pages-routing/overview.md): new "Active links" section with adapter examples and a matching table.
  • API reference: activeMatch and the two attributes on each adapter's Link.
  • void skill: a working rule pointing agents at the attributes instead of hand-written useRouter().path checks.

Test plan

  • linkActiveAttributes unit tests: exact and prefix matching, /, whole segments, query and hash, letter case, trailing slashes, encoding, relative and absolute hrefs, other origins, non-GET links, no router
  • React Link unit tests: server markup, prefix, overrides, no router
  • Vue type check: activeMatch accepts only 'exact' | 'prefix'
  • Shared browser contract, run against all four playgrounds in dev and preview (8/8): server HTML, state after hydration, and the nav updating across two SPA navigations. The playground layouts' Users link now uses activeMatch="prefix"
  • vp run check: 33/33
  • vp test run: 763/771 files passed under full load; the 7 failing files (Nuxt, SSR, TanStack Start, WebSocket playgrounds, a DB template test, a Sandbox runner test, a Cloudflare v2 test) all pass when rerun on their own

🤖 Generated with Claude Code

`Link` renders `aria-current="page"` and `data-void-active` when it points
to the current page, in all four adapters. `activeMatch="prefix"` keeps a
link active on nested pages; only an exact match gets `aria-current`.

Matching lives in `linkActiveAttributes` in `void/pages-client` and
follows the router: pathnames compare case-sensitively with trailing
slashes significant, the query and hash are ignored, and links to other
origins or with non-GET methods never match. A passed `aria-current` or
`data-void-active` overrides the computed value.

Closes voidzero-dev#676

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cpojer
cpojer previously approved these changes Nov 4, 2026

@cpojer cpojer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

@cpojer
cpojer merged commit 5900120 into voidzero-dev:main Nov 4, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Link: mark the link to the current page (aria-current, data-active)

2 participants