logo

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:

KeyAction
Enter / SpaceToggle the focused branch
ArrowRightExpand the branch, or move focus to the first child if already expanded
ArrowLeftCollapse the branch, or move focus to the parent if already collapsed
ArrowDownMove focus to the next visible branch
ArrowUpMove focus to the previous visible branch
HomeMove focus to the first branch in the tree
EndMove focus to the last visible branch
Tab / Shift+TabMove 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 or focusing any expander updates the roving target, so tabbing into the tree always lands on the last-interacted branch.

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 original StyleProps shipped the typoed key. The Svelte port accepts either at runtime for parity but logs a console.warn when only the typoed key is present. Prefer ariaLabels; 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.

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 on Enter', async () => {
    render(JsonView, { data: { nested: { inner: 1 } } })
    const expander = screen.getByRole('button', { name: /expand/i })
    await fireEvent.keyDown(expander, { key: 'Enter' })
    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 on Enter', async () => {
    render(JsonView, { data: { nested: { inner: 1 } } })
    const expander = screen.getByRole('button', { name: /expand/i })
    await fireEvent.keyDown(expander, { key: 'Enter' })
    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