In Vue Router 5, why does every page-number `<RouterLink>` on a job-search results page get `router-link-exact-active`, and how do you highlight only the current page?
answer
- matched by record, not URL
- query and hash are ignored
- ancestor active, current exact
- custom slot or useLink
- aria-current on every link
basics
~20 sRouterLink's active matching compares the route record and params only, never the query, so every ?page=N link on the same route is active and exact-active. Compare route.query.page yourself through custom with its slot, or through useLink.
solid answer
~40 sIn Vue Router 5 a RouterLink is active when its resolved record is in the current route's matched records and its params are included in the current params; it is exact-active when that record is the last one matched and the params are identical. Query and hash take no part. Pagination links like `{ query: { ...route.query, page: n } }` all resolve to the same record with the same params, so all of them get `router-link-exact-active` and `aria-current="page"`. To mark only the current page I render my own anchor with `<RouterLink custom v-slot="{ href, navigate }">` or `useLink()`, and set the class and `aria-current` from `route.query.page === String(n)`. The class names can be changed per link with `activeClass`/`exactActiveClass` or globally with `linkActiveClass`/`linkExactActiveClass`.
code
vue · 24 lines<script setup lang="ts">
import { computed } from 'vue'
import { useLink, useRoute } from 'vue-router'
const props = defineProps<{ page: number }>()
const route = useRoute()
const { href, navigate } = useLink({
to: computed(() => ({ query: { ...route.query, page: String(props.page) } })),
})
const isCurrent = computed(
() => String(route.query.page ?? '1') === String(props.page),
)
</script>
<template>
<a
:href="href"
:class="{ 'page-link--current': isCurrent }"
:aria-current="isCurrent ? 'page' : undefined"
@click="navigate"
>{{ page }}</a>
</template>go deeper
Recall the two default classes, router-link-active and router-link-exact-active, and that an ancestor route's link is active but not exact-active.
Explain matching by route record and params, why query and hash are ignored, and how activeClass, exactActiveClass and the global options rename the classes.
Show the fix in a reusable component: useLink or the custom slot, the current page decided from the query, and aria-current set on one link only.
Treat link state as design-system policy: one link component owning active styling and aria-current, so each feature does not reinvent query-aware highlighting.
## How RouterLink decides a link is active Vue Router 5's `<RouterLink>` tracks two states and applies a CSS class for each: | State | Default class | `createRouter` option | Per-link prop | |---|---|---|---| | active | `router-link-active` | `linkActiveClass` | `activeClass` | | exact-active | `router-link-exact-active` | `linkExactActiveClass` | `exactActiveClass` | The per-link prop wins over the router option, which wins over the default name. An exact-active link also gets `aria-current="page"` (the value comes from the `ariaCurrentValue` prop). A link is **active** when: 1. the route record its `to` resolves to appears among the current route's matched records, as the current route itself or as an ancestor, and 2. the link's params are all present, with equal values, in the current params. It is **exact-active** when, in addition, that record is the **last** matched record (the current route itself, not an ancestor) and the two sets of params are identical. ## What active matching ignores Matching is by **route record and params**. The link's `query` and `hash` play no part, and neither does the literal path: a link that goes through an `alias` still matches its record, and a record's `redirect` is not followed. This model dates from Vue Router 4, which removed the v3 `exact` prop and made matching record-based instead of path-based. ## Why every page number lights up A job-search results page often renders pagination like this: ```vue <RouterLink v-for="n in pageCount" :key="n" :to="{ query: { ...route.query, page: n } }">{{ n }}</RouterLink> ``` Every link resolves to the same `jobs` record with the same (empty) params. Only the query differs, and the query is ignored, so on `/jobs?page=3` **all** page links are active and exact-active. Every number is styled as current, and every one announces `aria-current="page"` to screen readers, which makes it an accessibility bug as well as a styling one. ## Highlighting only the current page You compare the query yourself. Two tools give you RouterLink's behaviour without its rendering: - **The `custom` prop with the default slot.** `<RouterLink custom v-slot="{ href, navigate }">` renders nothing of its own; you render the `<a>`, bind `:href` and `@click="navigate"`, and set the class and `aria-current` from your own condition. - **`useLink()`.** The composable behind RouterLink takes `to` (and optionally `replace`) and returns `route`, `href`, `isActive`, `isExactActive` and `navigate`, which suits a `PageLink` component in a design system. The condition is plain: `String(route.query.page ?? '1') === String(n)`. With `custom`, RouterLink applies **no** classes and **no** `aria-current` at all; that is the point, and also the trap, because forgetting to bind them leaves the current page unmarked. Note that the slot's and `useLink()`'s own `isActive` and `isExactActive` follow exactly the same record-and-params rules as the classes. They are just as blind to the query, so for pagination they do not help; the comparison with `route.query` is the part you add. ## The rules on concrete cases | Current URL | Link `to` | Active | Exact-active | |---|---|---|---| | `/jobs?page=3` | `/jobs?page=1` | yes | yes | | `/jobs?page=3` | `/jobs#filters` | yes | yes | | `/jobs/42` (child of `/jobs`) | `/jobs` | yes | no | | `/jobs/42` (child of `/jobs`) | `/jobs/7` | no | no | | `/jobs/42` (separate top-level record) | `/jobs` | no | no | The fourth row shows the params rule: `/jobs/7` resolves to the same child record, but its `id` param differs, so the link is neither active nor exact-active. ## Ancestors: active but not exact The other half interviewers probe is the top navigation. With a `/jobs` record whose child `:id` shows one posting (and no empty-path child), the header's `<RouterLink to="/jobs">` on `/jobs/42` is **active**, because its record is an ancestor, but not **exact-active**. Style section tabs with the active class and the current page with the exact class. If `/jobs` and `/jobs/:id` are separate top-level records instead, the header link is not active on a posting at all, whatever the shared path prefix suggests. ## Common mistakes - Styling pagination with `router-link-exact-active` and expecting the query to count. - Assuming a shared path prefix makes a link active. - Reaching for the v3 `exact` prop, which no longer exists. - Switching to `custom` and forgetting to bind the class and `aria-current`.
- Where do you change the active class names for the whole app instead of per link?Pass `linkActiveClass` and `linkExactActiveClass` to `createRouter()`. A link's own `activeClass` or `exactActiveClass` prop still overrides them, and the defaults `router-link-active` and `router-link-exact-active` apply when neither is set.
- What do you lose when you switch a RouterLink to the `custom` prop?RouterLink stops rendering its `<a>`, so it no longer applies the active classes, `aria-current` or the click handler for you. The slot hands you `href`, `navigate`, `route`, `isActive` and `isExactActive`; you must bind `href` and `navigate` to your own anchor and set the class and `aria-current` yourself.
saying these in an interview costs you the question
- A different query string makes a RouterLink inactive
- router-link-active means the current path starts with the link's path
- Adding the exact prop makes a RouterLink match exactly
- With the custom prop RouterLink still adds the active classes
- aria-current is only a styling hook, so duplicate values do no harm