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
ArrowRightExpand the branch; leave focus on its expander
ArrowLeftCollapse the branch; leave focus on its expander
ArrowDownMove focus to the next visible branch
ArrowUpMove focus to the previous 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 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 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.

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