Accessibility
<JsonView> implements the WAI-ARIA Treeview pattern out of the box. The root renders as role="tree", branches as role="treeitem", and expand toggles as role="button" with matching aria-expanded and aria-controls wiring.
DOM Shape
Simplified output for { user: { id: 1 } }:
<div role="tree">
<ul role="group">
<li role="treeitem" aria-expanded="true">
<span role="button"
aria-expanded="true"
aria-controls="sv-abc-group"
aria-label="collapse JSON">▾</span>
<span>user</span>
<ul id="sv-abc-group" role="group">
<li role="treeitem">
<span>id</span>: <span>1</span>
</li>
</ul>
</li>
</ul>
</div><div role="tree">
<ul role="group">
<li role="treeitem" aria-expanded="true">
<span role="button"
aria-expanded="true"
aria-controls="sv-abc-group"
aria-label="collapse JSON">▾</span>
<span>user</span>
<ul id="sv-abc-group" role="group">
<li role="treeitem">
<span>id</span>: <span>1</span>
</li>
</ul>
</li>
</ul>
</div>Every aria-controls is produced by Svelte 5’s $props.id() — the same ID is emitted server-side and client-side, so hydration never breaks the assistive-tech contract.
Keyboard Contract
When focus is on any expander button inside the tree:
| Key | Action |
|---|---|
ArrowRight | Expand the branch; leave focus on its expander |
ArrowLeft | Collapse the branch; leave focus on its expander |
ArrowDown | Move focus to the next visible branch |
ArrowUp | Move focus to the previous visible branch |
Tab / Shift+Tab | Move focus out of the tree (roving tabindex) |
The tree uses a roving tabindex — exactly one expander has tabindex="0" at a time, while the rest are tabindex="-1". Clicking an expander or navigating with ArrowUp/ArrowDown updates the roving target. Arrow navigation wraps between the first and last visible branches.
Screen Reader Labels
Each expander announces its state via aria-label. The default strings are:
{ collapseJson: 'collapse JSON', expandJson: 'expand JSON' }{ collapseJson: 'collapse JSON', expandJson: 'expand JSON' }Override for localization or terser wording by setting ariaLabels on your style map:
<script lang="ts">
import { JsonView, defaultStyles } from '@humanspeak/svelte-json-view-lite'
const style = {
...defaultStyles,
ariaLabels: { collapseJson: 'collapse', expandJson: 'expand' }
}
</script>
<JsonView data={payload} {style} /><script lang="ts">
import { JsonView, defaultStyles } from '@humanspeak/svelte-json-view-lite'
const style = {
...defaultStyles,
ariaLabels: { collapseJson: 'collapse', expandJson: 'expand' }
}
</script>
<JsonView data={payload} {style} />Note on
ariaLables(sic). react-json-view-lite’s originalStylePropsshipped the typoed key. The Svelte port accepts either at runtime for parity but logs aconsole.warnwhen only the typoed key is present. PreferariaLabels; the alias is removed in 2.0.
Click-to-Expand Considerations
When clickToExpandNode is enabled, labels become click targets but do not receive focus or a button role — the expander glyph remains the single keyboard-accessible activator. Keyboard users never see duplicated tab stops; mouse users get a larger hit target.
Set clickToExpandSummary={false} to disable expansion from collapsed summaries and custom
collapsed punctuation while retaining chevron clicks and arrow-key navigation. Label expansion
is independently controlled by clickToExpandNode.
Focus in custom expanders
The expander snippet receives both focused and focusVisible. focused includes native
focus retained after a mouse click. Use focusVisible for keyboard-only caret animation;
keep a visible focus indicator on the viewer-owned button. The snippet should render
noninteractive content inside that button.
A custom row snippet may add its own keyboard actions and tab stops. The Motion + Tailwind
example adds Enter/Space to copy a focused row; these are consumer actions, not built-in
expansion shortcuts. The viewer handles the four arrow keys on chevrons and does not add
Enter/Space or Home/End handlers.
SSR and Hydration
The tree is safe to render during SSR. Every aria-controls / id pair is produced by $props.id(), which is deterministic across server and client. No Math.random() or Date.now() — the IDs committed in the initial HTML match the ones rehydrated on the client.
Testing with @testing-library/svelte
The tree’s ARIA contract means queries work without implementation-specific selectors:
import { render, screen, fireEvent } from '@testing-library/svelte'
import { JsonView } from '@humanspeak/svelte-json-view-lite'
test('expands a branch with ArrowRight', async () => {
render(JsonView, { data: { nested: { inner: 1 } }, shouldExpandNode: () => false })
const expander = screen.getByRole('button', { name: /expand/i })
await fireEvent.keyDown(expander, { key: 'ArrowRight' })
expect(expander).toHaveAttribute('aria-expanded', 'true')
})import { render, screen, fireEvent } from '@testing-library/svelte'
import { JsonView } from '@humanspeak/svelte-json-view-lite'
test('expands a branch with ArrowRight', async () => {
render(JsonView, { data: { nested: { inner: 1 } }, shouldExpandNode: () => false })
const expander = screen.getByRole('button', { name: /expand/i })
await fireEvent.keyDown(expander, { key: 'ArrowRight' })
expect(expander).toHaveAttribute('aria-expanded', 'true')
})Known Limitations
- Custom snippets. Renderers you supply via snippet overrides are rendered inside the
<li role="treeitem">cell; if you embed interactive elements (links, buttons) they add their own tab stops outside the roving tabindex. Keep snippet content non-interactive when possible or scope focus management yourself. - Text direction. The arrow-key contract assumes LTR (“right opens, left closes”). RTL-aware swapping is not implemented.
- Virtualization. The tree is fully materialized — focus order matches DOM order. If you wrap the viewer in a virtualized list, the keyboard contract may not survive.
Related
- JsonView props — label and summary expansion controls
- Types & snippets —
AriaLabelsand the style shape