guideline

Accessibility

Pindoba targets WCAG 2.1 AA compliance by default, with AAA themes planned for the future.

Pindoba is built with accessibility as a core requirement. Every component targets WCAG 2.1 Level AA by default. AAA-compliant themes are planned for the future.

Our Philosophy

Accessibility is more important than making components look stunning. A beautiful component that some users can’t operate is a broken component. That said, accessible and beautiful are not mutually exclusive — we work hard to balance both.

When there’s a conflict, accessibility wins:

  • Contrast ratios always meet the minimum threshold, even if a lighter shade would “look nicer”
  • Focus indicators are always visible, even when they break a clean visual design
  • Interactive areas are always large enough to tap
  • Screen reader announcements are always clear

Contrast & Color

Text on accent and surface fills

Primary action (button, badge)bg: primary.accent.surface.peakcolor: primary.text.contrastmeasuring
Body text on surfacebg: neutral.surface.peakcolor: neutral.text.boldmeasuring
Danger / error statebg: danger.accent.surface.peakcolor: danger.text.contrast.boldmeasuring

Focus Indicators

---
import { css } from "@pindoba/styled-system/css";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Input from "@pindoba/astro-input";

const containerStyle = css({
  display: "flex",
  flexDirection: "column",
  gap: "lg",
});

// A framed section recesses into the peak reading sheet.
const sectionStyle = css.raw({
  backgroundColor: "neutral.surface.base",
  borderRadius: "lg",
  padding: "lg",
  border: "1px solid",
  borderColor: "neutral.border.muted",
});

const sectionTitleStyle = css.raw({
  fontSize: "md",
  fontWeight: "bold",
  marginBottom: "md",
  color: "neutral.text.bold",
});

const gridStyle = css.raw({
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
  gap: "md",
});

const cardStyle = css.raw({
  padding: "md",
  borderRadius: "md",
  display: "flex",
  flexDirection: "column",
  alignItems: "flex-start",
  gap: "xs",
});

const labelStyle = css.raw({
  fontSize: "xs",
  fontFamily: "mono",
});
---

<div class={containerStyle}>
  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>Text on accent and surface fills</h3>
    <div class={css(gridStyle)}>
      <div
        data-contrast-card
        class={css(cardStyle, {
          backgroundColor: "primary.accent.surface.peak",
          color: "primary.text.contrast",
        })}
      >
        <span>Primary action (button, badge)</span>
        <span class={css(labelStyle)}>bg: primary.accent.surface.peak</span>
        <span class={css(labelStyle)}>color: primary.text.contrast</span>
        <Badge tone="neutral" emphasis="secondary">
          <span data-contrast-ratio>measuring</span>
        </Badge>
      </div>

      <div
        data-contrast-card
        class={css(cardStyle, {
          backgroundColor: "neutral.surface.peak",
          color: "neutral.text.bold",
          borderStyle: "solid",
          borderWidth: "1px",
          borderColor: "neutral.border.muted",
        })}
      >
        <span>Body text on surface</span>
        <span class={css(labelStyle, { color: "neutral.text" })}>
          bg: neutral.surface.peak
        </span>
        <span class={css(labelStyle, { color: "neutral.text" })}>
          color: neutral.text.bold
        </span>
        <Badge tone="neutral" emphasis="secondary">
          <span data-contrast-ratio>measuring</span>
        </Badge>
      </div>

      <div
        data-contrast-card
        class={css(cardStyle, {
          backgroundColor: "danger.accent.surface.peak",
          color: "danger.text.contrast.bold",
        })}
      >
        <span>Danger / error state</span>
        <span class={css(labelStyle)}>bg: danger.accent.surface.peak</span>
        <span class={css(labelStyle)}>color: danger.text.contrast.bold</span>
        <Badge tone="neutral" emphasis="secondary">
          <span data-contrast-ratio>measuring</span>
        </Badge>
      </div>
    </div>
  </div>

  <div class={css(sectionStyle)}>
    <h3 class={css(sectionTitleStyle)}>Focus Indicators</h3>
    <div
      class={css({
        display: "flex",
        gap: "lg",
        flexWrap: "wrap",
        alignItems: "center",
      })}
    >
      <Button>Tab to focus me</Button>
      <Input placeholder="Focus this input" />
    </div>
  </div>
</div>

<script>
  // Measure the real ratio of each card's resolved text vs. fill colors, so the
  // badge can never claim a pass the tokens don't deliver.
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  const rgb = (value: string) => {
    if (!ctx) return [0, 0, 0];
    ctx.clearRect(0, 0, 1, 1);
    ctx.fillStyle = "#000";
    ctx.fillStyle = value;
    ctx.fillRect(0, 0, 1, 1);
    return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
  };
  const lum = (c: number[]) => {
    const [r, g, b] = c.map((v) => {
      const x = v / 255;
      return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };
  const measure = () => {
    document
      .querySelectorAll<HTMLElement>("[data-contrast-card]")
      .forEach((card) => {
        const out = card.querySelector<HTMLElement>("[data-contrast-ratio]");
        if (!out) return;
        const cs = getComputedStyle(card);
        const a = lum(rgb(cs.color));
        const b = lum(rgb(cs.backgroundColor));
        const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
        out.textContent = `${ratio.toFixed(1)}:1 ${ratio >= 4.5 ? "AA" : "below AA"}`;
      });
  };
  measure();
  new MutationObserver(measure).observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["data-theme-mode"],
  });
</script>

Semantic color tokens include .text.contrast variants for text on solid fills. They are solved against the family’s accent band (<family>.accent.surface.*, rest at shade 500), not against the pale surface.ground that panels use, so always pair them with the accent band:

import { css } from "@pindoba/styled-system/css";

<button
  className={css({
    backgroundColor: "primary.accent.surface.peak",
    color: "primary.text.contrast",
  })}
>
  Guaranteed readable text
</button>;

Color is never the only indicator. Always pair it with text, icons, or patterns:

// Color + icon + descriptive text
<span
  className={css({
    color: "danger.text.bold",
    display: "flex",
    alignItems: "center",
    gap: "xs",
  })}
>
  <ErrorIcon aria-hidden="true" />
  Error: your session has expired. Please sign in again.
</span>

Focus Management

All interactive components use :focus-visible for keyboard-only focus rings, powered by the pindobaOutline utility and the focusVisibleWithin condition:

<button
  className={css({
    _focusVisible: { pdbO: true },
  })}
>
  Tab to focus me
</button>

The focusVisibleWithin condition (&:has(:focus-visible, [data-focus-visible])) allows parent elements to react when any child receives keyboard focus.

Keyboard Navigation

Every Pindoba component is fully operable with a keyboard:

  • Buttons: Enter and Space to activate
  • Modal dialogs: Escape to close, focus is trapped inside (a non-modal dialog opened with show() has neither; the browser’s native trap belongs to showModal())
  • Tabs: Arrow keys to navigate between tabs
  • Checkboxes/Radios: Space to toggle, Arrow keys to navigate groups
  • Dropdowns: Arrow keys to navigate, Enter to select, Escape to close

Dialogs and popovers render in the browser’s top layer, above every z-index, and their ::backdrop makes the page behind inert for modal dialogs. Keep overlays inside <dialog> or popover rather than stacking positioned divs, so focus order and screen-reader scope follow what the user sees.

Reduced Motion

Components respect the user’s motion preferences via the _motionReduce condition:

<div
  className={css({
    pdbT: "300ms",
    _motionReduce: { transition: "none" },
  })}
>
  Animated content
</div>

Current & Future Compliance

Current: AA (Default Theme)

  • Text contrast: 4.5:1 minimum for normal text, 3:1 for large text
  • UI component contrast: 3:1 minimum for interactive elements
  • Focus indicators: Visible and meet contrast requirements
  • Keyboard access: All interactive content is operable via keyboard

Future: AAA Themes

Planned themes with enhanced accessibility:

  • High contrast — increased ratios meeting AAA (7:1 for normal text, 4.5:1 for large text)
  • High visibility focus — bolder, more prominent focus indicators
  • Reduced transparency — solid alternatives where alpha colors are used today

These themes will be opt-in and can be combined with light or dark mode.

Best Practices

For Component Authors

  1. Use native HTML elements (<button>, <input>, <select>) instead of <div> with click handlers
  2. Include visible text labels — icon-only buttons need aria-label
  3. Test with keyboard navigation before considering a component complete
  4. Use .text.contrast tokens for text on accent-band fills (primary.accent.surface.*)
  5. Respect prefers-reduced-motion for all animations

For Application Developers

  1. Provide meaningful page titles and heading hierarchy
  2. Include skip navigation links for content-heavy pages
  3. Ensure forms have visible labels and clear error messages
  4. Test with a screen reader periodically
  5. Don’t remove focus outlines — if the default style doesn’t fit, replace it with a visible alternative