Skip to content
svelte-vitals
English
Esc
navigateopen⌘Jpreview
On this page

a11y/id-duplication · Id duplication

Every id in a route should be unique.

Severity: warning · Category: a11y

What it checks

Flags a literal id value that occurs more than once across a route’s composed layout chain (every +layout.svelte up to the route’s +page.svelte) and its resolved local components. Detection is branch-aware: within an {#if}/{#await} block only the arm with the most occurrences counts (ties break to the first arm in document order), so the arms of one {#if} are not summed. Two separate {#if} blocks are independent, though: {#if a}…{/if}{#if !a}…{/if} is counted as both rendering, since nothing here evaluates a.

Ids are detected cross-file. An id="search" in +layout.svelte plus another id="search" in +page.svelte (or in an imported $lib component) is one duplicate, even though neither file alone looks wrong. A file-scoped markup linter cannot see this, since the duplication only exists once the layout and the page are composed.

Not flagged:

  • A route where every id is unique.
  • An {#each} body’s id, since the loop can render 0..N times and the resulting duplicate count is not knowable statically. Rendered analysis catches an actual duplicate (N≥2 renders) at runtime, by design.
  • A dynamic id (id={expr}), since its runtime value is unknown statically.

Why it matters

A duplicate id breaks the two things ids exist for: <label for>/aria-labelledby/aria-describedby resolve to the first element with that id, and in-page fragment navigation (#anchor) jumps to the first match too. If the author’s intent was the second element, a common outcome when a layout and a page both introduce the same id, assistive technology and fragment links silently point at the wrong element, with no visual sign anything is wrong.

How to fix

Rename one of the colliding ids so it’s unique within the route:

<nav id="site-search">...</nav>
<section id="page-search">...</section>
<!-- was id="site-search", collided with the layout -->

Mode differences

Ids are collected in both modes, but from different sources, so results can differ:

  • Source analysis (the CLI, the dashboard’s static baseline) composes the route’s layout chain with its resolved local components, using the branch-aware fold: within an {#if}/{#await} block only the arm with the most occurrences is credited, so it can pick a branch that would not actually render. It cannot see ids contributed by an unresolvable component (node_modules, a dynamically chosen component), and {#each} bodies are excluded since their id count is not knowable statically.
  • Both modes report a page id that collides with one in src/app.html: source analysis prepends the shell occurrence as the first, never-flagged representative, so the finding sits on the route-side occurrence and its message names the shell (— also defined by the src/app.html shell (line N)).
  • Rendered analysis (the Vite plugin’s build pass, a route you visit in the dashboard) reads the rendered HTML, so it sees only the ids that actually rendered, including every id an {#each} loop produced. A real duplicate from a loop only surfaces here. It has no source files to attribute a finding to, so its findings anchor to the route itself rather than a specific file and line. The persisted finding key differs from the source-analysis key for the same defect.

When the two disagree, trust the rendered result. It reflects what ships to the browser.

Disabling

An inline svelte-vitals-disable-next-line comment above the line the finding names silences it, in source analysis only: a build-pass finding points at the prerendered HTML and has no source line to sit above. That line often sits in a composed component, and one directive there silences the finding on every route composing it. The suppressions file (npx svelte-vitals --update-suppressions) is the per-route mechanism. You can also scope the rule per route or path with overrides, or turn it off:

export default {
  rules: {
    'a11y/id-duplication': 'off'
  }
};