Build accessible experiences

Boreal UI components are designed to provide accessible defaults, but the consuming app still controls the final experience. Use this guide when composing Boreal components into forms, pages, navigation, overlays, dashboards, and application workflows.

What Boreal UI Handles

These defaults work best when consumers provide meaningful labels, descriptions, and state values.

  • Semantic elements where a native element fits the job.
  • Keyboard behavior for interactive patterns.
  • Visible focus states.
  • Disabled-state handling.
  • ARIA props for labeling, descriptions, expanded state, pressed state, invalid state, busy state, and live updates where relevant.
  • Stable data-testid hooks for tests.
  • Accessible sorting announcements in DataTable.
  • Dialog semantics and close controls in Modal.
  • Label and description wiring in form controls where supported.

Consumer Responsibilities

The application remains responsible for the complete flow and final content quality.

  • Writing clear visible labels for form fields.
  • Providing aria-label or aria-labelledby for icon-only controls.
  • Supplying captions or accessible names for tables.
  • Keeping heading order meaningful in the page around Boreal components.
  • Preserving focus visibility when overriding styles.
  • Testing complete flows with real content, validation states, and async states.
  • Checking color contrast when you register custom color schemes or override CSS variables.

Implementation Patterns

Accessible Names

Interactive controls need an accessible name. Text children usually provide one automatically.

Accessible Names.tsx
import { Button } from "@boreal-ui/core";

export function SubmitAction() {
  return <Button type="submit">Submit request</Button>;
}

Icon-only Controls

Icon-only controls need an explicit label.

Icon-only Controls.tsx
import { IconButton } from "@boreal-ui/core";

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M6.2 4.8 12 10.6l5.8-5.8 1.4 1.4L13.4 12l5.8 5.8-1.4 1.4L12 13.4l-5.8 5.8-1.4-1.4 5.8-5.8-5.8-5.8 1.4-1.4Z"
        fill="currentColor"
      />
    </svg>
  );
}

export function CloseAction() {
  return <IconButton icon={CloseIcon} aria-label="Close dialog" />;
}

Visible External Labels

Use aria-labelledby when a visible element outside the control should provide the name.

Visible External Labels.tsx
<h2 id="billing-actions-title">Billing actions</h2>
<Button aria-labelledby="billing-actions-title">Open</Button>

Forms

Prefer visible labels. Do not rely on placeholder text as the only label.

Forms.tsx
import { TextInput } from "@boreal-ui/core";

export function EmailField() {
  return (
    <>
      <TextInput
        id="email"
        name="email"
        type="email"
        label="Email"
        aria-describedby="email-help"
        autoComplete="email"
        required
      />
      <p id="email-help">Use the address attached to your account.</p>
    </>
  );
}

Validation

Pass the component's error or invalid state props where available so assistive technology receives the same state as sighted users.

Validation.tsx
<>
  <TextInput
    id="project-name"
    name="projectName"
    label="Project name"
    state="error"
    aria-invalid
    aria-describedby="project-name-error"
  />
  <p id="project-name-error">Project name is required.</p>
</>

Buttons and Navigation

Use Button for actions and href for navigation.

Buttons and Navigation.tsx
<Button type="button" onClick={saveDraft}>
  Save draft
</Button>

<Button href="/settings">Open settings</Button>

Controlled Elements

When a button opens or controls another element, expose that relationship.

Controlled Elements.tsx
<Button
  aria-controls="project-menu"
  aria-expanded={menuOpen}
  aria-haspopup="menu"
  onClick={() => setMenuOpen((open) => !open)}
>
  Project actions
</Button>

Tables

Give DataTable a caption, aria-label, or aria-labelledby.

Tables.tsx
import { DataTable } from "@boreal-ui/core";

<DataTable
  caption="Invoices"
  columns={columns}
  data={rows}
  rowKey={(row) => row.id}
/>;

Semantic Captions

Use hideCaption when the table needs a semantic caption but the page already has a visible heading.

Semantic Captions.tsx
<h2 id="invoice-table-heading">Invoices</h2>
<DataTable
  aria-labelledby="invoice-table-heading"
  caption="Invoices"
  hideCaption
  columns={columns}
  data={rows}
/>;

Interactive Rows

For interactive rows, provide labels that explain the action.

Interactive Rows.tsx
<DataTable
  columns={columns}
  data={rows}
  onRowClick={(row) => openInvoice(row.id)}
  getRowAriaLabel={(row) => `Open invoice ${row.id}`}
/>;

Dialogs and Overlays

Modal should have a visible title, aria-label, or aria-labelledby. Add aria-describedby when supporting text explains the decision.

Dialogs and Overlays.tsx
import { Button, Modal } from "@boreal-ui/core";

export function DeleteProjectDialog({
  open,
  onClose,
}: {
  open: boolean;
  onClose: () => void;
}) {
  return (
    <Modal
      open={open}
      onClose={onClose}
      title="Delete project"
      aria-describedby="delete-project-description"
      closeButtonAriaLabel="Close delete project dialog"
    >
      <div>
        <p id="delete-project-description">
          This removes the project and its saved dashboard settings.
        </p>
        <Button state="warning">Delete project</Button>
      </div>
    </Modal>
  );
}

Loading and Async States

Use loading props and busy/live-region props where available.

Loading and Async States.tsx
<Button loading loadingLabel="Saving changes" aria-live="polite">
  Save changes
</Button>

Async Regions

Pair visual loading states with text that explains the state.

Async Regions.tsx
<DataTable
  aria-label="Search results"
  columns={columns}
  data={rows}
  loading={isLoading}
  loadingMessage="Loading search results"
  emptyMessage="No matching results"
/>;

Custom Color Schemes

Verify text, borders, focus indicators, success/error/warning states, outline variants, and glass surfaces against their backgrounds.

Custom Color Schemes.tsx
import { ThemeProvider } from "@boreal-ui/core";
import type { ColorScheme } from "@boreal-ui/types";

const schemes: ColorScheme[] = [
  {
    name: "Brand Night",
    primaryColor: "#4f46e5",
    secondaryColor: "#06b6d4",
    tertiaryColor: "#a855f7",
    quaternaryColor: "#22c55e",
    backgroundColor: "#0f172a",
    forceTextColor: "#ffffff",
  },
];

<ThemeProvider customSchemes={schemes} initialSchemeName="Brand Night">
  <App />
</ThemeProvider>;

Focus Styling

If you override --focus-outline-color, make sure it is visible on light, dark, glass, outlined, and themed surfaces.

Focus Styling.tsx
.app-action:focus-visible {
  outline: 2px solid var(--focus-outline-color);
  outline-offset: 2px;
}

Testing Library

Use Testing Library role and name queries first.

Testing Library.tsx
import { render, screen } from "@testing-library/react";
import { Button } from "@boreal-ui/core";

it("renders an accessible submit button", () => {
  render(<Button type="submit">Submit request</Button>);

  expect(
    screen.getByRole("button", { name: /submit request/i }),
  ).toBeInTheDocument();
});

Automated Checks

Use jest-axe for automated accessibility checks.

Automated Checks.tsx
import { render } from "@testing-library/react";
import { axe } from "jest-axe";
import { TextInput } from "@boreal-ui/core";

it("has no accessibility violations", async () => {
  const { container } = render(<TextInput label="Email" name="email" />);

  expect(await axe(container)).toHaveNoViolations();
});

Grouped Fields

For grouped fields, use FormGroup, fieldsets, headings, or helper text so related controls have context.

Disabled State

Avoid using disabled controls as the only way to explain what happened. Pair disabled states with visible helper text when the reason is not obvious.

Overlay Components

For Dropdown, PopOver, Tooltip, Tabs, Accordion, and CommandPalette, prefer the component's public props for labels and state. Avoid adding custom roles to wrapper elements unless the component API asks for them.

Styling Safely

  • Do not remove outlines unless you replace them with an equally visible focus style.
  • Avoid display: none for content that should remain available to screen readers.
  • Do not make disabled content look interactive.
  • Keep touch targets large enough for repeated use.
  • Check responsive layouts below 500px so labels, helper text, and controls do not overlap.

Testing

Automated checks are not enough on their own.

Also test these complete states and workflows before shipping.

  • Keyboard navigation through the complete workflow.
  • Focus order and focus return after overlays close.
  • Screen reader names for icon-only and custom controls.
  • Error messages and helper text.
  • Loading and empty states.
  • Custom themes and contrast.

Quick Checklist

Before shipping a page that uses Boreal UI:

  • Every control has an accessible name.
  • Every form field has a visible label or explicit ARIA label.
  • Icon-only controls have aria-label.
  • Tables have a caption or accessible label.
  • Dialogs have a title or accessible label.
  • Focus is visible after custom styling.
  • Validation errors are visible and announced through descriptions or invalid state.
  • Loading states include meaningful text.
  • Custom themes pass contrast checks.
  • Tests use roles and accessible names before test IDs.