Clawscale

Accessibility

Blueprint builds keyboard support and ARIA attributes into its components, and Clawscale keeps them. This page covers what your app still owns: focus, contrast, labels and motion.

Focus rings#

The focused element gets a 2px ring in --cs-color-focus. ClawscaleProvider shows it for keyboard focus only, so mouse clicks leave no ring. Pass focusRings="always" to show it for every focus, as Blueprint does by default. FocusStyleManager switches the same behavior at runtime.

Turn off the switch, click a button, then press Tab.

import { Button, FocusStyleManager, InputGroup, Switch } from "@clawscale/react";
import { useEffect, useState } from "react";

export default function AccessibilityFocus() {
  const [keyboardOnly, setKeyboardOnly] = useState(false);

  useEffect(() => setKeyboardOnly(FocusStyleManager.isActive()), []);

  const toggle = (checked: boolean) => {
    if (checked) FocusStyleManager.onlyShowFocusOnTabs();
    else FocusStyleManager.alwaysShowFocus();
    setKeyboardOnly(checked);
  };

  return (
    <div style={{ display: "grid", gap: 12, width: 320 }}>
      <Switch
        checked={keyboardOnly}
        label="Show focus only for keyboard"
        onChange={(event) => toggle(event.currentTarget.checked)}
      />
      <InputGroup aria-label="Pipeline name" placeholder="Pipeline name" />
      <div style={{ display: "flex", gap: 8 }}>
        <Button intent="primary" text="Run pipeline" />
        <Button text="Cancel" />
      </div>
    </div>
  );
}

--cs-color-focus keeps at least 3:1 contrast against every surface in every theme and color scheme. Use it for focus styles in your own components.

.run-row:focus-visible {
  outline: 2px solid var(--cs-color-focus);
  outline-offset: 2px;
}

Color contrast#

Unit tests check the color tokens of every theme against WCAG AA in both color schemes. They live in packages/tokens/test/tokens.test.ts and run in CI.

PairMinimum ratio
text, text-muted and text-subtle on every surface4.5:1
text on hover, active and selected rows4.5:1
text on controls in every state4.5:1
Intent -on text on its solid fill, at rest, hover and active4.5:1
Intent -text on surface and on its -subtle fill4.5:1
neutral-on on neutral fills4.5:1
tooltip-text on tooltip4.5:1
focus on every surface3:1

text-disabled is exempt: WCAG does not require contrast for disabled controls. If you override a color token, check its pairs again.

Never show status by color alone. Pair an intent with an icon or a label.

Icon-only buttons#

A button with only an icon has no text for screen readers. Give it an aria-label. Wrap it in a Tooltip with the same text for mouse users. The tooltip also opens on keyboard focus.

import { Button, Tooltip } from "@clawscale/react";

export default function AccessibilityIconButtons() {
  return (
    <>
      <Tooltip content="Refresh">
        <Button icon="refresh" variant="minimal" aria-label="Refresh" />
      </Tooltip>
      <Tooltip content="Filter rows">
        <Button icon="filter" variant="minimal" aria-label="Filter rows" />
      </Tooltip>
      <Tooltip content="Download CSV">
        <Button icon="download" variant="minimal" aria-label="Download CSV" />
      </Tooltip>
      <Tooltip content="Delete pipeline">
        <Button icon="trash" intent="danger" variant="minimal" aria-label="Delete pipeline" />
      </Tooltip>
    </>
  );
}

Icons and inputs#

Blueprint hides icons from screen readers by default. Pass title to an Icon that carries meaning on its own. Label every input with FormGroup and labelFor, or with aria-label.

Reduced motion#

Clawscale stops the skeleton pulse when the system asks for reduced motion. Blueprint's other animations ignore that setting. Read prefers-reduced-motion and stop motion you do not need, such as progress bar stripes.

Backfill 64% done. Stripes move.
import { Classes, ProgressBar } from "@clawscale/react";
import { useSyncExternalStore } from "react";

const QUERY = "(prefers-reduced-motion: reduce)";

function subscribe(onChange: () => void) {
  const media = window.matchMedia(QUERY);
  media.addEventListener("change", onChange);
  return () => media.removeEventListener("change", onChange);
}

function usePrefersReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  );
}

export default function AccessibilityReducedMotion() {
  const reduced = usePrefersReducedMotion();
  return (
    <div style={{ display: "grid", gap: 8, width: 320 }}>
      <ProgressBar aria-label="Backfill progress" animate={!reduced} intent="primary" value={0.64} />
      <span className={Classes.TEXT_MUTED}>
        Backfill 64% done. {reduced ? "Reduced motion is on: stripes stay still." : "Stripes move."}
      </span>
    </div>
  );
}