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.
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:
---
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>
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.
Every Pindoba component is fully operable with a keyboard:
Enter and Space to activateEscape to close, focus is trapped inside (a non-modal dialog opened with show() has neither; the browser’s native trap belongs to showModal())Arrow keys to navigate between tabsSpace to toggle, Arrow keys to navigate groupsArrow keys to navigate, Enter to select, Escape to closeDialogs 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.
Components respect the user’s motion preferences via the _motionReduce condition:
<div
className={css({
pdbT: "300ms",
_motionReduce: { transition: "none" },
})}
>
Animated content
</div>
Planned themes with enhanced accessibility:
These themes will be opt-in and can be combined with light or dark mode.
<button>, <input>, <select>) instead of <div> with click handlersaria-label.text.contrast tokens for text on accent-band fills (primary.accent.surface.*)prefers-reduced-motion for all animations