logo svelte /virtual chat v0.1.20

Accessibility

SvelteVirtualChat ships with keyboard navigation, a labeled focusable scroll region, and reduced-motion support out of the box. This guide covers what the component provides and what your app should add on top.

file · AccessibilityChat.svelte mode · live running source
Message 1 — Keyboard users can reach this viewport with Tab.
keyboard · readout
tab to focus, then scroll keys — watch following
focused
no
last key
following
yes
key · action
↑ / ↓
one line
PageUp / PageDn
one page
Space / ⇧Space
one page
Home
oldest · unfollow
End
newest · re-follow

Focusable, labeled scroll region

The scrollable viewport is a landmark that keyboard and screen reader users can reach directly:

  • role="region" with an accessible name from the viewportLabel prop (default: "Chat messages")
  • tabindex="0" so it participates in the tab order
<SvelteVirtualChat {messages} getMessageId={(m) => m.id} viewportLabel="Support conversation">
    <!-- ... -->
</SvelteVirtualChat>
<SvelteVirtualChat {messages} getMessageId={(m) => m.id} viewportLabel="Support conversation">
    <!-- ... -->
</SvelteVirtualChat>

Set viewportLabel to something meaningful for your context — “Chat messages” is a reasonable default, but “Conversation with Alex” tells a screen reader user which conversation they just landed in.

Keyboard navigation

When the viewport itself is focused, the standard scrolling keys work — and they participate in the same follow-bottom logic as wheel and touch input:

KeyAction
ArrowUp / ArrowDownScroll by 40px
PageUp / PageDownScroll by 85% of the viewport height
Space / Shift+SpacePage down / page up
HomeJump to the oldest message (disengages follow)
EndJump to the newest message and re-engage follow

Follow-bottom semantics are input-agnostic:

  • Scrolling up past followBottomThresholdPx disengages follow — new messages stop pulling the view down, exactly as with a wheel or trackpad.
  • Scrolling down while at the bottom never disengages follow, so holding ArrowDown during a stream is safe.
  • End is the keyboard equivalent of a “jump to newest” button.

Interactive message content keeps its keys

The component only intercepts keys when the viewport element itself has focus. Inputs, buttons, links, and any other interactive elements rendered inside messages receive their keystrokes untouched — Space in a textarea types a space; it does not page the chat.

Focus and virtualization

Because messages are virtualized, off-screen messages are removed from the DOM entirely. If the user has focused a control inside a message — a button, a link, an input — and then scrolls that message out of the rendered range, the browser drops focus to document.body. When the message scrolls back in, it re-mounts as a fresh element and focus is not restored to it.

This is deliberate: the component does not manage focus inside message content. Restoring focus on re-entry would steal it from wherever the user has since moved — the composer, a toolbar, another message — which is more disruptive than the drop itself.

Design around it rather than fighting it:

  • Keep primary interactive flows — the composer, action bars, “jump to newest” — outside the scroll viewport, where they never virtualize.
  • For in-message controls that start a flow (a “retry” or “copy” button on a message), complete that flow in UI that lives outside the virtualized region so focus has a stable home.
  • If your app genuinely needs focus restoration for in-message controls, track the focused message id yourself and re-focus the control when its message re-mounts.

Reduced motion

When the user has prefers-reduced-motion: reduce set, the smooth-scroll easing for new messages is disabled and the viewport lands instantly. No configuration needed.

Programmatic scrolling is respected

Assistive tooling and app code that scrolls programmatically (for example scrollTo from a “jump to message” feature) is treated as user navigation: follow-bottom disengages instead of yanking the view back to the bottom — even while messages are streaming in.

What your app should add

The component deliberately stays out of two areas that need app-level context:

Announce new messages with a live region

Messages are virtualized — only the visible slice exists in the DOM — so screen readers cannot discover off-screen messages on their own, and an aria-live attribute on message content would only fire for rendered nodes. Announce new messages yourself with a visually hidden polite live region, summarizing rather than duplicating:

<script lang="ts">
    let announcement = $state('')

    function addMessage(msg: Message) {
        messages.push(msg)
        announcement = `${msg.author}: ${msg.content}`
    }
</script>

<div class="sr-only" role="status" aria-live="polite">{announcement}</div>
<script lang="ts">
    let announcement = $state('')

    function addMessage(msg: Message) {
        messages.push(msg)
        announcement = `${msg.author}: ${msg.content}`
    }
</script>

<div class="sr-only" role="status" aria-live="polite">{announcement}</div>

Throttle announcements during token streaming — announce when a message completes, not on every token.

Pair follow state with a visible control

onFollowBottomChange tells you when the user has scrolled away. Use it to show a focusable “jump to newest” button so the affordance exists for pointer, keyboard, and screen reader users alike:

<SvelteVirtualChat
    bind:this={chat}
    {messages}
    getMessageId={(m) => m.id}
    onFollowBottomChange={(following) => (showJumpButton = !following)}
>
    <!-- ... -->
</SvelteVirtualChat>

{#if showJumpButton}
    <button onclick={() => chat.scrollToBottom({ smooth: true })}> Jump to newest </button>
{/if}
<SvelteVirtualChat
    bind:this={chat}
    {messages}
    getMessageId={(m) => m.id}
    onFollowBottomChange={(following) => (showJumpButton = !following)}
>
    <!-- ... -->
</SvelteVirtualChat>

{#if showJumpButton}
    <button onclick={() => chat.scrollToBottom({ smooth: true })}> Jump to newest </button>
{/if}

End already does this from the keyboard when the viewport is focused; the button covers everyone else.