Styling and Accessibility

Keyboard Navigation, Dynamic Content, and ARIA


Learning Objectives

  • You understand what keyboard accessibility is and can test applications using keyboard only.
  • You can manage focus programmatically and implement skip links for navigation.
  • You understand what ARIA is, when to use it, and know common ARIA attributes.

Many users navigate the web without a mouse. This includes people with motor disabilities, blind users who rely on screen readers, power users who prefer keyboard shortcuts, and anyone temporarily unable to use a pointing device. For these users, inaccessible keyboard navigation means they cannot use your application at all.

Keyboard Interactions

The primary keyboard interactions for web navigation are Tab to move focus to the next interactive element, Shift + Tab to move backward, Enter to activate buttons and follow links, Space to activate buttons and toggle checkboxes, Escape to close modals and dismiss menus, and arrow keys to navigate within components like dropdown menus or tab panels.

The Tab key is the primary navigation tool — users press Tab to move through all interactive elements on a page in order.

When a user presses Tab, the browser moves focus to the next focusable element. By default, focusable elements include links, buttons, form inputs, and elements with a tabindex attribute. The order in which elements receive focus — called the tab order — follows the order elements appear in the HTML source code.

<header>
  <nav>
    <a href="/">Home</a>  <!-- First -->
    <a href="/about">About</a>  <!-- Second -->
    <a href="/contact">Contact</a>  <!-- Third -->
  </nav>
</header>
<main>
  <button>Click me</button>  <!-- Fourth -->
  <label>
    Search
    <input type="text" />  <!-- Fifth -->
  </label>
</main>
Loading Exercise...

When an element receives keyboard focus, it must have a visible focus indicator so users can see where they are on the page. Browsers provide default focus indicators (usually a blue or black outline), but these are sometimes removed by developers who find them visually unappealing. Removing focus indicators without providing alternatives makes your application unusable for keyboard users.

The CSS pseudo-class :focus applies when an element has focus. A newer pseudo-class, :focus-visible, applies only when the browser determines that the focus indicator should be visible, typically during keyboard navigation but not mouse clicks.

<button>Click me</button>

<style>
/* Bad: removes focus indicators entirely */
button:focus {
  /* outline: none;  <-- Don't do this */
}

/* Good: custom focus indicator */
button:focus-visible {
  outline: 3px solid #007bff;
  outline-offset: 2px;
}
</style>

In Tailwind CSS, you can use the focus-visible: variant to style focus indicators:

<button class="focus-visible:outline focus-visible:outline-3 focus-visible:outline-blue-500 focus-visible:outline-offset-2">
  Click me
</button>
Loading Exercise...

While browsers handle basic focus behavior automatically, there are situations where you need to manage focus programmatically to create a good user experience. For example, if a user opens a modal dialog, the focus should go into that dialog, and when the user closes the dialog, the focus should return to the element that triggered opening the dialog.

Moving focus to an element

In JavaScript (and in Svelte), an element is focused on with the focus() method. To identify the element we need to focus on, with Svelte, we can use the bind:this directive to get a reference to the element. The following example shows a component with a button, which when clicked, moves focus to the text input:

<script>
  let nameInput;

  const focusOnNameInput = () => {
    nameInput.focus();
  }
</script>

<button onclick={focusOnNameInput}>Focus the input</button>
<label>
  Name
  <input bind:this={nameInput} type="text" />
</label>

Trapping focus

By default, tabbing on a page moves focus through all focusable elements in the tab order. If the user interface has a component that should retain focus within it, such as a modal dialog (a pop-up), we wish to constrain tabbing to that component only. This is called a focus trap.

A focus trap keeps focus within a specific part of the page, preventing users from tabbing outside that area. Focus traps are used, for example, with modal dialogs: when a modal is open, focus should cycle through elements within the modal, not escape to the page behind it.

Concretely, this is done by overriding the Tab and Shift + Tab key behavior to cycle focus within the modal. The following example showcases a draft Svelte 5 modal component with a focus trap implementation:

<script>
	let isOpen = $state(false);
	let modalElement = $state(null);
	let previouslyFocused = $state(null);

	const openModal = () => {
		previouslyFocused = document.activeElement;
		isOpen = true;
	};

	const closeModal = () => {
		isOpen = false;
		previouslyFocused?.focus();
	};

	const handleKeydown = (event) => {
		if (event.key === 'Escape') {
			closeModal();
			return;
		}

		if (event.key !== 'Tab') return;

		const focusableElements = modalElement.querySelectorAll(
			'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
		);
		const firstElement = focusableElements[0];
		const lastElement = focusableElements[focusableElements.length - 1];

		if (event.shiftKey) {
			if (document.activeElement === firstElement) {
				event.preventDefault();
				lastElement.focus();
			}
		} else {
			if (document.activeElement === lastElement) {
				event.preventDefault();
				firstElement.focus();
			}
		}
	}

	$effect(() => {
		if (isOpen && modalElement) {
			const focusableElements = modalElement.querySelectorAll(
				'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
			);
			focusableElements[0]?.focus();
		}
	});
</script>

<button onclick={openModal}>Open Modal</button>

{#if isOpen}
	<div class="overlay" onclick={closeModal}>
		<div
			bind:this={modalElement}
			class="modal"
			role="dialog"
			aria-modal="true"
			onkeydown={handleKeydown}
			onclick={(e) => e.stopPropagation()}
		>
			<p>Try tabbing through the elements.</p>
			<input type="text" placeholder="Tab-tabiti-tab-tab-tab" />
			<div class="buttons">
				<button>Action</button>
				<button onclick={closeModal}>Close (or press Escape)</button>
			</div>
		</div>
	</div>
{/if}

<style>
	.overlay {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.modal {
		background: white;
	}
</style>

The above component outlines the basic idea: when the modal opens, it saves the previously focused element and moves focus into the modal. The handleKeydown function traps focus within the modal by cycling focus when Tab or Shift + Tab is pressed. When the modal closes, focus returns to the element that opened it.

Accidental focus traps are situations where users cannot escape a section of the page. This goes without saying, but these should be avoided.

Loading Exercise...

Skip links allow keyboard users to bypass repetitive content like navigation menus and jump directly to the main content. Without skip links, a user may have to tab through dozens of navigation links on every page just to reach the content they want to read.

A skip link is typically the first focusable element on the page and links to the main content area:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Page with Skip Link</title>
    <style>
      .skip-link {
        position: absolute;
        top: -40px;
        left: 0;
      }
      .skip-link:focus {
        top: 0;
      }
    </style>
  </head>
  <body>
    <a href="#main-content" class="skip-link">Skip to main content</a>

    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href="/services">Services</a>
        <a href="/portfolio">Portfolio</a>
        <a href="/contact">Contact</a>
      </nav>
    </header>

    <main id="main-content" tabindex="-1">
      <h1>Welcome to Our Site</h1>
      <p>Main content starts here...</p>
    </main>
  </body>
</html>

The skip link is visually hidden by default (positioned off-screen) but becomes visible when it receives focus. The link points to the id of the main content area, and that element has tabindex="-1" so it can receive focus programmatically when the skip link is activated.

Loading Exercise...

Accessible Rich Internet Applications (ARIA)

Accessible Rich Internet Applications (ARIA) is a set of attributes that provide additional semantic information to assistive technologies when HTML alone is not sufficient.

ARIA helps make dynamic, JavaScript-driven content accessible.

ARIA does not change how elements look or behave — it only affects how assistive technologies interpret and announce elements. ARIA adds meaning through three types of attributes: roles that define what an element is (like role="button" or role="dialog"), properties that describe characteristics (like aria-label="Close" or aria-labelledby="title-id"), and states that describe current conditions (like aria-expanded="true" or aria-checked="false").

No ARIA vs bad ARIA

No ARIA is better than bad ARIA. ARIA exists to bridge gaps when semantic HTML cannot express the necessary meaning. For example, HTML has no native element for a tab panel, so ARIA provides role="tablist", role="tab", and role="tabpanel" to describe this pattern.

<!-- Bad: div with ARIA -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>

<!-- Good: semantic HTML -->
<button onclick="submit()">Submit</button>

The <button> element automatically has the correct role, is keyboard accessible, responds to Enter and Space, and has proper focus management. Adding role="button" to a <div> gives it the correct role, but you would still need to manually implement keyboard support, focus management, and other behaviors that <button> provides automatically.

Loading Exercise...

Common ARIA Attributes

The aria-label attribute provides a text label for an element, useful for icon buttons where the label isn’t visible. The aria-labelledby attribute references another element by its id to use as the label, which is helpful for connecting sections with their headings. The aria-describedby attribute provides additional description beyond the label.

<button aria-label="Close dialog">
  <svg><!-- X icon --></svg>
</button>

<section aria-labelledby="section-title">
  <h2 id="section-title">User Settings</h2>
  <!-- section content -->
</section>

<label>
  Password
  <input type="password" aria-describedby="password-help" />
</label>
<p id="password-help">Must be at least 8 characters</p>

The aria-hidden="true" attribute hides content from assistive technologies while keeping it visually visible. It can be used for decorative elements, redundant icons, or content that would be confusing if announced. Do not use aria-hidden on focusable elements — if an element can receive focus, screen readers should be able to announce it.

The aria-expanded attribute indicates whether a collapsible element is open or closed. When the element opens, JavaScript should update the attribute to aria-expanded="true". The aria-selected attribute indicates the currently selected item in a group, commonly used in tab interfaces. The aria-current attribute indicates the current item in a navigation or process, helping users understand which page they’re on.

<button aria-expanded="false" onclick="toggleMenu()">
  Menu
</button>

<div role="tablist">
  <button role="tab" aria-selected="true">Tab 1</button>
  <button role="tab" aria-selected="false">Tab 2</button>
</div>

<nav>
  <a href="/" aria-current="page">Home</a>
  <a href="/about">About</a>
</nav>
Loading Exercise...

Dynamic content and live regions

Consider the following component that shows a text when a button is pressed:

<script>
  let text = $state("");

  const changeText = () => {
    text = "Hello world";
  };
</script>

<button onclick={changeText}>Change text</button>
<p>{text}</p>

When the button is pressed, a sighted user will see the text “Hello world” appear on screen. A screen reader user, however, hears nothing. Screen readers do not automatically announce content that appears after the page has loaded.

ARIA live regions provide a way to inform screen readers about dynamic content changes so they can announce them to users. An ARIA live region is an area of the page where changes should be announced to screen reader users. When content in a live region changes, screen readers interrupt what they’re doing to announce the update. The aria-live attribute marks an element as a live region:

  • For changes that should be announced politely (when the screen reader is idle), use aria-live="polite".
  • For changes that should be announced immediately (interrupting the current task), use aria-live="assertive".

In the example below, p element of the component is now marked as a polite live region, so when the text changes, screen readers will announce it:

<script>
  let text = $state("");

  const changeText = () => {
    text = "Hello world";
  };
</script>

<button onclick={changeText}>Change text</button>
<p aria-live="polite">{text}</p>

Common use cases for live regions include form validation messages that appear dynamically, status updates like loading or saving states, notifications and alerts, real-time updates such as new messages or score changes, and search results that update as you type.

Keep announcements concise and meaningful. A message like “Loading…” is better than “Please wait while we are loading your data. This may take a few moments.” Don’t overuse live regions — too many announcements become noise and frustrate users.

Loading Exercise...

Summary

In summary:

  • Keyboard accessibility is essential for users who cannot use a mouse. All functionality must be available via keyboard using Tab, Enter, Space, Escape, and arrow keys.
  • Focus indicators must be visible so keyboard users can see where they are. Style :focus-visible in CSS or use Tailwind’s focus-visible: utilities.
  • Manage focus programmatically when opening modals, closing them, or removing content. Skip links allow keyboard users to bypass repetitive navigation and jump to main content.
  • ARIA provides additional semantics when HTML is insufficient, but no ARIA is better than bad ARIA. Always prefer semantic HTML over ARIA attributes.
  • Common ARIA attributes include aria-label, aria-labelledby, aria-describedby, aria-hidden, aria-expanded, and aria-selected.
  • ARIA live regions announce dynamic content changes to screen reader users. Use aria-live="polite" for status messages and aria-live="assertive" for urgent alerts.
  • Interactive patterns like modals, dropdowns, form validation, and accordions require proper ARIA attributes, focus management, and keyboard support to be accessible.