Foundations
FoundationsTier 2 · Primitives
Accessibility hooks
Focus trap, escape key, click-outside, scroll lock, arrow-key navigation and contrast maths.
bunx --bun shadcn@latest add @woohl/accessibilityReference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/accessibilityregistry/tier-2-primitives/lib/accessibility.ts
"use client";
/**
* Accessibility helpers.
*
* Two things live here: the contrast maths the brand documentation is built on,
* and the small set of React helpers components need so that "accessible by
* default" is the path of least resistance rather than a checklist.
*/
import { useEffect, useRef } from "react";
import { CONTRAST } from "@/registry/tier-1-tokens/lib/constants";
/* --------------------------------------------------------------- contrast */
function srgbToLinear(channel: number): number {
const v = channel / 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}
/** WCAG 2.1 relative luminance. Accepts #RGB or #RRGGBB. */
export function luminance(hex: string): number {
const h = hex.replace("#", "");
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return (
0.2126 * srgbToLinear(r) +
0.7152 * srgbToLinear(g) +
0.0722 * srgbToLinear(b)
);
}
export function contrast(a: string, b: string): number {
const la = luminance(a);
const lb = luminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
export type ContrastVerdict = {
ratio: number;
/** Passes AA for body text (4.5:1). */
aa: boolean;
/** Passes AA for large text — 24px, or 19px bold (3:1). */
aaLarge: boolean;
/** Passes AAA for body text (7:1). */
aaa: boolean;
/** Passes the 3:1 minimum for UI component boundaries and icons. */
ui: boolean;
label: "AAA" | "AA" | "AA Large" | "Fail";
};
export function verdict(fg: string, bg: string): ContrastVerdict {
const ratio = contrast(fg, bg);
const aa = ratio >= CONTRAST.text;
const aaLarge = ratio >= CONTRAST.large;
const aaa = ratio >= CONTRAST.enhanced;
return {
ratio,
aa,
aaLarge,
aaa,
ui: ratio >= CONTRAST.ui,
label: aaa ? "AAA" : aa ? "AA" : aaLarge ? "AA Large" : "Fail",
};
}
/** Pick whichever of Ink or Paper reads better on a given background. */
export function bestOn(bg: string): "#111111" | "#FFFFFF" {
return contrast("#111111", bg) >= contrast("#FFFFFF", bg)
? "#111111"
: "#FFFFFF";
}
/* ------------------------------------------------------------------ react */
/**
* Screen-reader-only text. For the word an icon-only control needs but the
* layout can't show.
*
* Not `hidden` and not `display: none` — both remove it from the accessibility
* tree, which is the opposite of the point.
*/
export const srOnly =
"absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0 [clip-path:inset(50%)]";
/**
* Escape closes. Every overlay in this folder uses it, so "Escape works" is not
* something anyone has to remember to implement.
*/
export function useEscapeKey(enabled: boolean, onEscape: () => void): void {
useEffect(() => {
if (!enabled) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onEscape();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [enabled, onEscape]);
}
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
/**
* Trap focus inside a container while it is open, and return focus to whatever
* opened it on close.
*
* A modal that lets Tab escape into the page behind it is a modal in appearance
* only — a screen-reader or keyboard user is now editing a form they cannot
* see.
*/
export function useFocusTrap<T extends HTMLElement>(
enabled: boolean,
): React.RefObject<T | null> {
const containerRef = useRef<T>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!enabled) return;
const container = containerRef.current;
if (!container) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const first = container.querySelector<HTMLElement>(FOCUSABLE);
(first ?? container).focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Tab" || !container) return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
previouslyFocused.current?.focus?.();
};
}, [enabled]);
return containerRef;
}
/**
* Lock body scroll while an overlay is open, compensating for the scrollbar so
* the page behind doesn't jump sideways as it disappears.
*/
export function useScrollLock(enabled: boolean): void {
useEffect(() => {
if (!enabled) return;
const { overflow, paddingRight } = document.body.style;
const gutter = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (gutter > 0) document.body.style.paddingRight = `${gutter}px`;
return () => {
document.body.style.overflow = overflow;
document.body.style.paddingRight = paddingRight;
};
}, [enabled]);
}
/**
* Close when a click lands outside the element. Pointerdown rather than click,
* so a drag that starts inside and ends outside doesn't close it.
*/
export function useClickOutside<T extends HTMLElement>(
enabled: boolean,
onOutside: () => void,
): React.RefObject<T | null> {
const ref = useRef<T>(null);
useEffect(() => {
if (!enabled) return;
function onPointerDown(event: PointerEvent) {
const el = ref.current;
if (el && !el.contains(event.target as Node)) onOutside();
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [enabled, onOutside]);
return ref;
}
/**
* Roving-tabindex arrow-key navigation for a composite widget — tabs, a
* segmented control, a menu.
*
* Returns the handler; the caller owns the index so the same hook works for a
* controlled tablist and an uncontrolled menu.
*/
export function useArrowNavigation(options: {
itemCount: number;
activeIndex: number;
onChange: (index: number) => void;
orientation?: "horizontal" | "vertical";
loop?: boolean;
}) {
const {
itemCount,
activeIndex,
onChange,
orientation = "horizontal",
loop = true,
} = options;
return function onKeyDown(event: React.KeyboardEvent) {
const next = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
const prev = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
let target: number | null = null;
if (event.key === next) target = activeIndex + 1;
else if (event.key === prev) target = activeIndex - 1;
else if (event.key === "Home") target = 0;
else if (event.key === "End") target = itemCount - 1;
if (target === null) return;
event.preventDefault();
if (target < 0) target = loop ? itemCount - 1 : 0;
if (target >= itemCount) target = loop ? 0 : itemCount - 1;
onChange(target);
};
}
This item exports no props — it is a stylesheet, a helper or a set of constants.
Registry items
npm packages
None beyond React itself.