@entropy-softworks/curbcut
v2026.9.10
Published
Reusable React Native accessibility primitives: language selection, accessibility preferences, and the providers that carry them through an Expo app
Maintainers
Readme
curbcut
Reusable React Native accessibility primitives for Entropy Softworks apps.
Named for the curb-cut effect: the ramps cut into sidewalks after 1970s disability activism turned out to serve everyone pushing a stroller, a cart or a suitcase. Accessibility is not a concession to a minority — it is the work that makes a product better for all of its users. This package exists so that work is written once and inherited, rather than re-argued in every product.
npm install @entropy-softworks/curbcutWhat is in it
| Surface | What it does |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| A11yProvider / useA11y() | Holds the language choice and the accessibility preferences, persists them, and seeds them from the OS on a first run |
| LanguageSetupScreen | Full first-run screen: search, the language wheel, an options drawer, Continue |
| AccessibilitySettingsScreen | The same settings for an authenticated settings stack |
| LanguagePicker / LanguageWheel | The wheel on its own — a snapping carousel whose middle row is the selection |
| A11yOptionsPanel | Every preference as a row |
| LanguageCoin | A language's flag, drawn as a coin, in react-native-svg |
| useHaptics / useReduceMotion / useA11yTextStyle | The preferences, applied |
Quick start
Wrap the app once, naming the languages it actually has translations for:
import { A11yProvider, LANGUAGES } from "@entropy-softworks/curbcut";
const OFFERED = LANGUAGES.filter((l) => ["en-US", "es-ES", "fr", "ja"].includes(l.code));
export default function RootLayout() {
return (
<A11yProvider languages={OFFERED} fallbackLanguage="en-US">
<Stack />
</A11yProvider>
);
}Then put the setup screen in front of your own first question:
import { LanguageSetupScreen } from "@entropy-softworks/curbcut";
export default function AccessibilitySetup() {
const router = useRouter();
return <LanguageSetupScreen onContinue={() => router.replace("/where-to-store-your-data")} />;
}And read the state anywhere:
const { preferences, language, t, textScaleFactor } = useA11y();Tailwind / NativeWind
curbcut's components use NativeWind classes for layout, so the consumer's Tailwind build has to see
its source. Add the resolved package path to content:
const curbcutSrc = path.join(
fs.realpathSync(path.resolve(__dirname, "node_modules/@entropy-softworks/curbcut")),
"src/**/*.{js,jsx,ts,tsx}"
);
module.exports = {
presets: [require("@entropy-softworks/curbcut/tailwind-preset")],
content: ["./app/**/*.{js,jsx,ts,tsx}", curbcutSrc],
};realpathSync rather than a literal glob: in a workspace the package is hoisted to the monorepo
root and ./node_modules/@entropy-softworks/curbcut/src matches nothing — silently, producing a
build whose stylesheet is missing every class used only inside this package. The preset is optional
if you already extend entropy-ui's; the two define the same core tokens.
Two design rules worth knowing before you extend it
No new native modules. Everything here is pure JS, core react-native, or a native module the
consumer already has. The system locale comes from Intl and I18nManager, not
expo-localization; the flags are hand-drawn SVG, not an icon set. The reason is delivery: a new
native module changes the Expo fingerprint, so consumers on the fingerprint runtimeVersion policy
can no longer reach their installed builds over the air. An accessibility package that costs a full
rebuild to adopt is one that gets deferred to next quarter, every quarter.
The wheel is a real ScrollView. Not a pan gesture. A hand-rolled carousel would give better
control over the physics and would also throw away everything a ScrollView gets for free —
momentum, snapping, the platform scroll sound, and the part that actually matters here: a screen
reader can page through its children and a switch-control user can move focus through them. An
accessibility package cannot ship an inaccessible control as its centrepiece.
Language coverage
LANGUAGES carries 37 entries with a coin and both names for each. Two things about it:
- It is a registry, not a policy. Pass the subset you have translations for. The default is the whole list, which is right for a demo and a lie in a shipping app.
- curbcut's own strings — the search placeholder, the option labels, Continue — are translated into 15 locales and fall back to English for the rest. That gap is deliberate: the wheel has to be readable before a language is chosen, and an English fallback is a known quantity where a machine-guessed translation is not. Adding a locale is a merge request with a native speaker on it.
Arabic has no flag coin. It is official in more than twenty states and picking one of their flags to mean "Arabic" is a political statement rather than a localisation decision, so it gets a script-glyph coin. Any future language in the same position should do the same.
The flags themselves are drawn to be legible at 34 points, not to be exact. Where a charge cannot
survive that size it is simplified or dropped, and every such decision is named in a comment at the
component that makes it — see src/flags/custom.tsx.
Preferences curbcut cannot apply for you
reduceMotion, haptics and textScale are honoured by curbcut's own components. highContrast
and colorFilter are recorded only — mapping them onto a palette needs the app's colours, so
the app has to do it:
const { preferences } = useA11y();
const palette = preferences.highContrast ? HIGH_CONTRAST : DEFAULT;Development
npm install
npm run check-all # type-check, lint, format, test
pwsh scripts/pipeline.ps1 -Stage test # the gate CI runsSee CONTRIBUTING.md. Security policy in SECURITY.md.
License
MIT — see LICENSE.
