Skip to main content

Accessibility

Soft UI is built with accessibility as a core principle. All components follow WCAG 2.1 Level AA standards and implement ARIA patterns from Base UI.

Base UI Foundation

Soft UI components are built on Base UI primitives, which provide:
  • Complete ARIA patterns - All components have proper roles, states, and properties
  • Keyboard navigation - Full keyboard support out of the box
  • Focus management - Automatic focus trapping and restoration
  • Screen reader announcements - Live regions and semantic markup

Focus Ring Implementation

Soft UI uses a dual-ring focus indicator for maximum visibility.

Focus Ring Utilities

From packages/tokens/src/utilities.css:185-194:
[data-slot="accordion-item"]:has([data-slot="accordion-trigger"]:focus-visible) {
  box-shadow: 0 0 0 1px var(--color-utility-focus-inner),
    0 0 0 3px var(--color-utility-focus-outer);
}

CSS Variables

  • --color-utility-focus-inner - 1px inner ring (high contrast)
  • --color-utility-focus-outer - 3px outer ring (brand color with opacity)

Implementation Pattern

From packages/react/src/components/button.tsx:56-57:
const buttonVariants = cva(
  "focus-visible:shadow-[0_0_0_1px_var(--color-utility-focus-inner),0_0_0_3px_var(--color-utility-focus-outer)]"
)

Custom Component Focus Rings

import { cn } from "@soft-ui/react/lib/utils"

function CustomCard({ children, ...props }) {
  return (
    <div
      tabIndex={0}
      className={cn(
        "rounded-lg p-4",
        "outline-none",
        "focus-visible:shadow-[0_0_0_1px_var(--color-utility-focus-inner),0_0_0_3px_var(--color-utility-focus-outer)]"
      )}
      {...props}
    >
      {children}
    </div>
  )
}

Keyboard Navigation

All interactive components support keyboard navigation.

Standard Keyboard Patterns

ComponentKeysBehavior
ButtonEnter, SpaceActivate button
DialogEscapeClose dialog
Menu Navigate items
MenuEnter, SpaceSelect item
MenuEscapeClose menu
Tabs Navigate tabs
TabsHome, EndFirst/last tab
Select Navigate options
SelectEnter, SpaceOpen/select
Combobox Navigate options
ComboboxEnterSelect option
ComboboxEscapeClose dropdown
Slider Adjust value
SliderHome, EndMin/max value
CheckboxSpaceToggle checked
Radio Navigate options
SwitchSpaceToggle on/off

Example: Tab Navigation

import { Tabs } from "@soft-ui/react/tabs"

// Keyboard navigation automatically supported:
// - Arrow keys to navigate between tabs
// - Home/End for first/last tab
// - Tab key to move focus out of tab list
<Tabs defaultValue="tab1">
  <Tabs.List>
    <Tabs.Tab value="tab1">Profile</Tabs.Tab>
    <Tabs.Tab value="tab2">Settings</Tabs.Tab>
    <Tabs.Tab value="tab3" disabled>Disabled</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="tab1">Profile content</Tabs.Panel>
  <Tabs.Panel value="tab2">Settings content</Tabs.Panel>
</Tabs>

ARIA Patterns

Data Attributes

Base UI components use data attributes for state management. These automatically provide ARIA attributes.

Common Data Attributes

// Button states
data-disabled       // aria-disabled="true"
data-pressed        // aria-pressed="true" (Toggle)

// Selection states
data-selected       // aria-selected="true"
data-checked        // aria-checked="true"
data-indeterminate  // aria-checked="mixed"
data-active         // aria-current="page" (Tabs)

// Interactive states
data-highlighted    // aria-activedescendant
data-open           // aria-expanded="true"

Real Example: Select Component

import { Select } from "@soft-ui/react/select"

<Select defaultValue="option1">
  {/* Trigger has aria-haspopup="listbox" */}
  <Select.Trigger>
    <Select.Value placeholder="Select..." />
    <Select.Icon />
  </Select.Trigger>
  <Select.Portal>
    <Select.Positioner>
      {/* Popup has role="listbox" */}
      <Select.Popup>
        <Select.List>
          {/* Each item has role="option" and data-selected */}
          <Select.Item value="option1">
            <Select.ItemText>Option 1</Select.ItemText>
            <Select.ItemIndicator />
          </Select.Item>
        </Select.List>
      </Select.Popup>
    </Select.Positioner>
  </Select.Portal>
</Select>

Required ARIA Labels

Some components require explicit labels for screen readers.

Icon-Only Buttons

import { IconButton } from "@soft-ui/react/icon-button"
import { RiMoreLine } from "@remixicon/react"

// Always provide aria-label for icon buttons
<IconButton aria-label="More options" variant="ghost">
  <RiMoreLine />
</IconButton>

Custom Interactive Elements

// Custom clickable card
<div
  role="button"
  tabIndex={0}
  aria-label="View project details"
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault()
      handleClick()
    }
  }}
>
  <h3>Project Name</h3>
  <p>Description</p>
</div>

Live Regions

For dynamic content updates, use ARIA live regions.

Toast Notifications

import { Toast } from "@soft-ui/react/toast"

// Toast.Root automatically has role="status" and aria-live="polite"
<Toast.Provider>
  <Toast.Portal>
    <Toast.Viewport position="bottom-right">
      <Toast.Root variant="card">
        <Toast.Content>
          <Toast.Icon tone="success" />
          <Toast.TextWrapper>
            <Toast.Title>File uploaded</Toast.Title>
            <Toast.Description>Your file has been uploaded successfully.</Toast.Description>
          </Toast.TextWrapper>
          <Toast.Close />
        </Toast.Content>
      </Toast.Root>
    </Toast.Viewport>
  </Toast.Portal>
</Toast.Provider>

Custom Live Region

function StatusMessage({ message, priority = "polite" }) {
  return (
    <div
      role="status"
      aria-live={priority}
      aria-atomic="true"
      className="sr-only" // Screen reader only
    >
      {message}
    </div>
  )
}

<StatusMessage message="3 new messages" priority="polite" />
<StatusMessage message="Error: Form submission failed" priority="assertive" />

Screen Reader Considerations

Semantic HTML

Use semantic HTML elements for better screen reader support:
// Good - semantic button
<Button>Submit</Button>

// Bad - div with click handler
<div onClick={handleClick}>Submit</div>

Visually Hidden Content

Use the sr-only utility class for screen reader-only content:
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
import { Badge } from "@soft-ui/react/badge"

function NotificationBadge({ count }) {
  return (
    <>
      <Badge variant="danger">{count}</Badge>
      <span className="sr-only">{count} unread notifications</span>
    </>
  )
}

Descriptive Labels

Provide context for screen reader users:
import { Input } from "@soft-ui/react/input"
import { Field } from "@soft-ui/react/field"

// Good - associated label
<Field>
  <Field.Label>Email address</Field.Label>
  <Input type="email" />
  <Field.Description>We'll never share your email.</Field.Description>
</Field>

// Bad - placeholder as label
<Input type="email" placeholder="Email" />

Focus Management

Focus Trapping

Modals and dialogs automatically trap focus:
import { Dialog } from "@soft-ui/react/dialog"

// Focus is automatically trapped within the dialog
// Tab cycles through interactive elements inside
// Escape closes the dialog and restores focus
<Dialog.Root>
  <Dialog.Trigger>Open Dialog</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Backdrop />
    <Dialog.Popup>
      <Dialog.Content>
        <Dialog.Title>Dialog Title</Dialog.Title>
        <Dialog.Body>
          <Input placeholder="Focus trapped here" />
        </Dialog.Body>
        <Dialog.Footer>
          <Dialog.Close>Cancel</Dialog.Close>
          <Button>Confirm</Button>
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog.Popup>
  </Dialog.Portal>
</Dialog.Root>

Manual Focus Management

import { useRef, useEffect } from "react"
import { Input } from "@soft-ui/react/input"

function SearchDialog({ isOpen }) {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (isOpen) {
      // Focus input when dialog opens
      inputRef.current?.focus()
    }
  }, [isOpen])

  return (
    <Input ref={inputRef} placeholder="Search..." />
  )
}
function Layout({ children }) {
  return (
    <>
      <a
        href="#main-content"
        className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50"
      >
        Skip to main content
      </a>
      <nav>Navigation</nav>
      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  )
}

Color Contrast

All Soft UI color tokens meet WCAG 2.1 Level AA contrast requirements:
  • Normal text: Minimum 4.5:1 contrast ratio
  • Large text: Minimum 3:1 contrast ratio
  • UI components: Minimum 3:1 contrast ratio

Text Colors

// High contrast text (7:1+)
<p className="text-content-strong">Primary content</p>

// Medium contrast text (4.5:1+)
<p className="text-content-default">Secondary content</p>

// Subtle text (4.5:1 minimum)
<p className="text-content-subtle">Tertiary content</p>

Testing Contrast

Use browser DevTools or tools like:

Motion & Animation

Respect user motion preferences:
import { motion, useReducedMotion } from "framer-motion"

function AnimatedCard() {
  const shouldReduceMotion = useReducedMotion()

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: shouldReduceMotion ? 0.01 : 0.25,
      }}
    >
      Card content
    </motion.div>
  )
}
See Animation Guidelines for more details.

Form Accessibility

Field Component

Use the Field component for accessible form inputs:
import { Field } from "@soft-ui/react/field"
import { Input } from "@soft-ui/react/input"

<Field>
  <Field.Label>Username</Field.Label>
  <Input name="username" required />
  <Field.Description>Choose a unique username.</Field.Description>
  <Field.Error>Username is already taken.</Field.Error>
</Field>
This provides:
  • Proper <label> association via htmlFor
  • aria-describedby for descriptions and errors
  • aria-invalid when error is present

Error Announcements

import { useState } from "react"
import { Field } from "@soft-ui/react/field"
import { Input } from "@soft-ui/react/input"

function EmailField() {
  const [error, setError] = useState("")

  return (
    <Field>
      <Field.Label>Email</Field.Label>
      <Input
        type="email"
        onChange={(e) => {
          const isValid = e.target.validity.valid
          setError(isValid ? "" : "Please enter a valid email")
        }}
      />
      {error && (
        <Field.Error role="alert">
          {error}
        </Field.Error>
      )}
    </Field>
  )
}

Testing Accessibility

Automated Testing

Use tools like:

Manual Testing

  1. Keyboard Navigation
    • Tab through all interactive elements
    • Verify focus indicators are visible
    • Test all keyboard shortcuts
  2. Screen Reader Testing
    • Test with NVDA (Windows) or VoiceOver (macOS)
    • Verify all content is announced
    • Check heading hierarchy
  3. Color Contrast
    • Test in light and dark modes
    • Verify all states (hover, focus, disabled)
    • Check with color blindness simulators

Best Practices

  1. Use semantic HTML - Prefer <button> over <div onClick>
  2. Provide labels - All form inputs and icon buttons need labels
  3. Maintain focus visibility - Never remove focus outlines without a replacement
  4. Test with keyboard - Ensure all functionality is keyboard accessible
  5. Respect user preferences - Honor reduced motion and high contrast settings
  6. Use ARIA sparingly - Rely on semantic HTML first, ARIA as enhancement
  7. Test with screen readers - Automated tools miss many issues

Build docs developers (and LLMs) love