You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Link: mark the link to the current page (aria-current, data-active) #676
Link renders a plain <a> with no sign that it points to the current page. Every navigation menu (a header, a sidebar, tabs) has to compare useRouter().path with each link's href by hand. Those hand-written checks usually style the active link but forget aria-current="page", so screen readers aren't told which link is the current page.
Example
What an app writes today:
import{Link,useRouter}from'@void/react';// The section's home link only matches itself; the others also match their nested pages.functionisActive(path: string,href: string,home: string){returnpath===href||(href!==home&&path.startsWith(`${href}/`));}functionSidebar({ links, home }: {links: {href: string;label: string}[];home: string}){const{ path }=useRouter();returnlinks.map((link)=>{constactive=isActive(path,link.href,home);return(<Linkkey={link.href}href={link.href}data-active={active||undefined}aria-current={active ? 'page' : undefined}>{link.label}</Link>);});}
aria-current="page" when the link's path is the current path. It's semantically "this is the current page", so exact match only.
data-active on the same link, for styling with [data-active] or Tailwind's data-active:. Unlike a class, it doesn't collide with the app's own className.
An opt-in for nested pages, for example activeMatch="prefix", so /admin/users stays highlighted on /admin/users/42. It matches whole path segments, so /admin/users doesn't match /admin/users-archive.
Ignore the query and hash when matching, so /inbox?page=2 still marks /inbox.
The names are only suggestions. The router already holds the current path in every adapter, so this should look the same in React, Vue, Svelte and Solid. #672, which moves the shared Link logic into void/pages-client, looks like the natural place for the matching.
Prior art
React Router:NavLink sets aria-current="page" and an active class, and takes end to choose between exact and nested matching.
TanStack Router:Link sets aria-current="page" and data-status="active", and takes activeOptions: { exact, includeSearch } and activeProps.
Vue Router:RouterLink adds router-link-active for nested matches, and router-link-exact-active plus aria-current="page" for exact ones.
Nuxt:NuxtLink takes activeClass and exactActiveClass.
Link marks the link to the current page with aria-current="page" and data-void-active. It matches exactly by default, and activeMatch="prefix" keeps a link active on nested pages. The behavior is the same in React, Vue, Svelte and Solid.
Exact by default. A prefix default would also mark links in page content, like a /docs link in a footer on every docs page. An exact default keeps both attributes in agreement unless a link opts in.
aria-current on exact matches only. It means "this page", so a nested page shouldn't get it. Nested pages that want this can specify activeMatch="prefix".
data-void-active attribute as a styling hook. It follows data-void-form-error, the existing presence-only attribute that Form renders in all four adapters. We don't use a class or call it just data-active, the -void prefix avoids colliding with the app's own attributes, and a class would need merging four different ways.
Query and hash ignored. The rule stays path-only, and the server can render the same attributes the client does. The cost: links to query-driven tabs or in-page sections are marked active along with their page.
An aria-current or data-void-active passed to Link overrides the computed value.
Matching rules
Resolve the href against the current URL and compare pathnames. A link to another origin is never active.
Path.exact: the same pathname. prefix: the same pathname, or one that continues with / after the link's path. Only whole segments match, and / only matches itself.
Query and hash are ignored, on both the link and the current URL.
Method. Links that aren't GET render a <button> and are never active.
Examples
✓ = rendered, — = not rendered.
Exact matching (default)
Current URL
Link
data-void-active
aria-current
Why
/admin/users
href="/admin/users"
✓
"page"
Same path
/admin/users/42
href="/admin/users"
—
—
Nested page, exact mode
Nested pages (activeMatch="prefix")
Current URL
Link
data-void-active
aria-current
Why
/admin/users
href="/admin/users"
✓
"page"
An exact match still gets aria-current
/admin/users/42
href="/admin/users"
✓
—
Nested page
/admin/users-archive
href="/admin/users"
—
—
Whole segments only
/admin/users
href="/"
—
—
/ only matches itself
Query and hash
Current URL
Link
data-void-active
aria-current
Why
/inbox?page=2
href="/inbox"
✓
"page"
Query ignored
/settings?tab=billing
href="/settings?tab=profile"
✓
"page"
Query ignored on the link too
/docs
href="/docs#install"
✓
"page"
Hash ignored
Edge cases
Current URL
Link
data-void-active
aria-current
Why
/
href="https://example.com/"
—
—
Another origin
/logout
href="/logout" method="post"
—
—
Renders a <button>
/checkout/shipping
href="/checkout/shipping" aria-current="step"
✓
"step"
A passed value wins
Open questions
Trailing slashes: should href="/admin/" match /admin? I think it should follow how Void's router treats the two URLs.
Letter case: should href="/Admin" match /admin? I think yes, since URLs are generally case-insensitive and most other routers do that.
Not in this proposal
activeClass / activeProps, ({ isActive }) children, a public matching hook, options for matching the query or hash, and data-pending. Each could be added later without a breaking change.
Activity
yangshun commentedon Nov 3, 2026
Proposal: active state for
LinkLinkmarks the link to the current page witharia-current="page"anddata-void-active. It matches exactly by default, andactiveMatch="prefix"keeps a link active on nested pages. The behavior is the same in React, Vue, Svelte and Solid.Key decisions
/docslink in a footer on every docs page. An exact default keeps both attributes in agreement unless a link opts in.aria-currenton exact matches only. It means "this page", so a nested page shouldn't get it. Nested pages that want this can specifyactiveMatch="prefix".data-void-activeattribute as a styling hook. It followsdata-void-form-error, the existing presence-only attribute thatFormrenders in all four adapters. We don't use a class or call it justdata-active, the-voidprefix avoids colliding with the app's own attributes, and a class would need merging four different ways.API
One new prop:
Two attributes that
Linkrenders:data-void-activeactiveMatch="prefix"aria-current="page"activeMatchis set toThese attributes can be used for styling.
An
aria-currentordata-void-activepassed toLinkoverrides the computed value.Matching rules
exact: the same pathname.prefix: the same pathname, or one that continues with/after the link's path. Only whole segments match, and/only matches itself.<button>and are never active.Examples
✓ = rendered, — = not rendered.
Exact matching (default)
Linkdata-void-activearia-current/admin/usershref="/admin/users""page"/admin/users/42href="/admin/users"Nested pages (
activeMatch="prefix")Linkdata-void-activearia-current/admin/usershref="/admin/users""page"aria-current/admin/users/42href="/admin/users"/admin/users-archivehref="/admin/users"/admin/usershref="/"/only matches itselfQuery and hash
Linkdata-void-activearia-current/inbox?page=2href="/inbox""page"/settings?tab=billinghref="/settings?tab=profile""page"/docshref="/docs#install""page"Edge cases
Linkdata-void-activearia-current/href="https://example.com/"/logouthref="/logout" method="post"<button>/checkout/shippinghref="/checkout/shipping" aria-current="step""step"Open questions
href="/admin/"match/admin? I think it should follow how Void's router treats the two URLs.href="/Admin"match/admin? I think yes, since URLs are generally case-insensitive and most other routers do that.Not in this proposal
activeClass/activeProps,({ isActive })children, a public matching hook, options for matching the query or hash, anddata-pending. Each could be added later without a breaking change.cpojer commentedon Nov 3, 2026
I like the proposal. Make sure to add docs for it too so agents discover it.
feat(pages): mark the Link to the current page (#687)