IGNE's React component kit for the web: base-ui and react-aria primitives wrapped as compound parts, presets over them, every form control in three tiers with a TanStack Form bridge, and DTCG design tokens that each app themes. It is the web counterpart of @igne-agency/react-native-ui, with the same names and semantics where both have the concept.
The package ships built ESM, one module per source file, each importing only its own CSS. An app needs no Vite, Sass or React Compiler configuration for it.
bun add @igne-agency/react-web-ui
bun add react react-dom @base-ui/react react-aria-components @internationalized/date @tanstack/react-form temporal-polyfillSome entry points need an optional peer as well:
| Import | Needs |
|---|---|
@igne-agency/react-web-ui/composer |
lexical, @lexical/react, @lexical/history, @lexical/plain-text |
@igne-agency/react-web-ui/data-table |
@tanstack/react-table |
@igne-agency/react-web-ui/data-view |
@tanstack/react-query |
@igne-agency/react-web-ui/unsaved-changes/tanstack-router |
@tanstack/react-router |
@igne-agency/react-web-ui/terrazzo and react-web-ui-tokens |
@terrazzo/cli, @terrazzo/parser, @terrazzo/plugin-css |
Lexical is a peer because it must be a single copy per app: an app that uses Lexical itself shares the kit's.
Import each component from its own subpath, such as @igne-agency/react-web-ui/button. Everything else has one too: ./forms, ./hooks, ./storage, ./table-storage, ./tone and ./unsaved-changes. The app then loads the CSS of what it imports and of the components those render, and no other.
The root, @igne-agency/react-web-ui, re-exports all of it but the entries that need an optional peer. A production build tree-shakes it, but Vite's dev server pre-bundles a package import with every export it has, so a single import from the root loads every component's stylesheet in development.
Import the reset and a theme once, at the app's entry:
import "@igne-agency/react-web-ui/base.css";
import "@igne-agency/react-web-ui/tokens.css";base.css resets the user agent's styles and sets the body's font and colour, ::selection, links and their focus ring. It also sets isolation: isolate on #root and position: relative on body, which base-ui's popups need. It loads no fonts; the app loads its own.
tokens.css is the kit's default theme: the --ref-* ramps and the --sys-* roles, light on :root and dark on html[data-theme="dark"], and under prefers-color-scheme: dark unless data-theme="light".
All kit CSS sits in the cascade layers kit.reset, kit.base and kit.components, so any unlayered CSS in the app wins without a specificity fight. Each component reads its own custom properties ("knobs"), defaulting to a --sys-* role, so a stylesheet can retheme one component by setting its knobs.
An app with its own brand builds its own tokens.css instead of importing the kit's. Its resolver layers the kit's token files and the app's own overrides:
{
"$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json",
"name": "App",
"version": "2025.10",
"sets": {
"base": {
"sources": [
{
"$ref": "../../node_modules/@igne-agency/react-web-ui/tokens/ref.tokens.json"
},
{
"$ref": "../../node_modules/@igne-agency/react-web-ui/tokens/sys.tokens.json"
},
{ "$ref": "brand.tokens.json" }
]
}
},
"modifiers": {
"theme": {
"contexts": {
"light": [],
"dark": [
{
"$ref": "../../node_modules/@igne-agency/react-web-ui/tokens/dark.tokens.json"
}
]
},
"default": "light"
}
},
"resolutionOrder": [
{ "$ref": "#/sets/base" },
{ "$ref": "#/modifiers/theme" }
]
}The ./terrazzo preset builds it into the custom properties the components read:
// terrazzo.config.ts
import { defineConfig } from "@terrazzo/cli";
import kitTokens from "@igne-agency/react-web-ui/terrazzo";
export default defineConfig({
tokens: ["./src/style/tokens/app.resolver.json"],
outDir: "./src/style/",
plugins: [kitTokens({ filename: "tokens.gen.css" })],
});Build it with the kit's react-web-ui-tokens command, not tz build:
{
"scripts": {
"tokens": "react-web-ui-tokens"
}
}tz build loads terrazzo.config.ts through a Vite dev server, and that server also loads the app's own vite.config.ts. Every plugin in it runs, including dev-only ones such as an HTTPS certificate plugin, even when the tokens build as part of a production build. react-web-ui-tokens runs the same Terrazzo build without Vite. It reads terrazzo.config.* from the working directory and needs Bun.
The resolver needs a theme modifier with light and dark contexts. Like the kit's own tokens.css, the file the preset writes opens with the kit's layer order and keeps every rule in kit.base, so an unlayered override in the app still wins. The preset names every token by its path with dots as hyphens (sys.color.primary becomes --sys-color-primary), and writes a shadow tagged $extensions["nl.igne.css"].property: "filter" as drop-shadow() filters. Typography tokens name no family, so the preset narrows Terrazzo's core/valid-typography rule to fontSize, fontWeight and lineHeight.
The kit names no typeface and loads no font. Every type role reads its family from one of two custom properties: --sys-font-family-heading for headings and --sys-font-family-body for everything else. The body family defaults to system-ui, sans-serif, and the heading family follows the body family. To use a typeface, load it the way the app loads any font, then name it on :root in unlayered CSS:
:root {
--sys-font-family-body: "Inter", system-ui, sans-serif;
--sys-font-family-heading:
"Satoshi", system-ui, sans-serif;
}This works the same over the kit's tokens.css and over a theme the preset builds.
The kit owns no breakpoints and no gutters; those are the app's layout.
The kit renders no icons and depends on no icon set. Wherever a component shows one, it leaves a slot the app fills from its own set: a part's children, or, on a ready-made component, a prop named for the icon's job (icon, closeIcon, expandIcon). The slot sizes the glyph to its text and colours it, so an SVG drawn at 1em in currentColor fits as is. A slot is required only where the icon is all the control shows, such as a close button's cross; anywhere else, left out, nothing renders.
import ExpandMoreIcon from "@material-design-icons/svg/outlined/expand_more.svg?react";
import { Select } from "@igne-agency/react-web-ui/select-field";
<Select.Icon>
<ExpandMoreIcon />
</Select.Icon>;Icon, from ./icon, sizes and labels any SVG component: label="" hides a glyph that stands beside its word, and a label names one that stands alone.
The kit has no catch-all provider. Wrap the app in the ones it uses:
import { LocaleProvider } from "@igne-agency/react-web-ui/locale-provider";
import { TimeZoneProvider } from "@igne-agency/react-web-ui/time-zone-provider";
import { ToastProvider } from "@igne-agency/react-web-ui/toast";
import { Tooltip } from "@igne-agency/react-web-ui/tooltip";
import CloseIcon from "./icons/close.svg?react";
export const Providers = ({
children,
}: Readonly<{ children: React.ReactNode }>) => (
<LocaleProvider locale="nl-NL">
<TimeZoneProvider timeZone="Europe/Amsterdam">
<Tooltip.Provider>
<ToastProvider
closeLabel="Close"
closeIcon={<CloseIcon />}
viewportLabel="Notifications">
{children}
</ToastProvider>
</Tooltip.Provider>
</TimeZoneProvider>
</LocaleProvider>
);LocaleProvider takes a BCP 47 locale and TimeZoneProvider an IANA time zone. Both are required: useLocale() and useTimeZone() throw outside them, and nothing falls back to the device.
The kit has no copy of its own. Every string a component shows or announces is a prop named for what it says, such as clearLabel or retryLabel, so the app passes its own translations.
Every control comes in three tiers:
- the raw compound, such as
TextInputorSelect - the field shorthand with label, description and errors, such as
TextFieldorSelectField - the TanStack Form bridge, such as
TSFTextField, which the app registers on its form hook
createAppForm builds the app's form hook from the bridges it registers, once, in a module of the app's own. A form reaches each bridge through form.AppField under its key, and loads the code and CSS of those bridges only:
// src/form.ts
import { TSFEmailField } from "@igne-agency/react-web-ui/email-field";
import { createAppForm } from "@igne-agency/react-web-ui/forms";
export const { useAppForm, withForm, withFieldGroup } =
createAppForm({ EmailField: TSFEmailField });import { Form } from "@igne-agency/react-web-ui/form";
import { useAppForm } from "./form.ts";
const SignUp = () => {
const form = useAppForm({
defaultValues: { email: "" },
validators: { onSubmit: schema },
onSubmit: ({ value }) => save(value),
});
return (
<Form.Root form={form}>
<form.AppField name="email">
{field => (
<field.EmailField
label="Email"
requiredness="required"
optionalLabel="optional"
/>
)}
</form.AppField>
<Form.Errors />
<Form.Submit busyLabel="Signing up">
Sign up
</Form.Submit>
</Form.Root>
);
};Related fields, such as the parts of an address, share one legend and one error in a field group, which lines them up side by side. Register TSFFieldGroup as a form component and name the fields whose errors it shows:
import { TSFFieldGroup } from "@igne-agency/react-web-ui/fieldset";
import { TSFTextField } from "@igne-agency/react-web-ui/text-field";
export const { useAppForm, withForm, withFieldGroup } =
createAppForm(
{ TextField: TSFTextField },
{ FieldGroup: TSFFieldGroup },
);<form.FieldGroup
legend="Address"
optionalLabel="optional"
names={["postalCode", "houseNumber"]}>
<form.AppField name="postalCode">
{field => (
<field.TextField
label="Postal code"
requiredness="required"
optionalLabel="optional"
/>
)}
</form.AppField>
<form.AppField name="houseNumber">
{field => (
<field.TextField
label="House number"
requiredness="required"
optionalLabel="optional"
/>
)}
</form.AppField>
</form.FieldGroup>Every field takes requiredness ("required", "optional", or left out to show neither mark) and errors, which accepts strings and anything with a string message, such as Standard Schema issues. Validation is the app's own, through Standard Schema; the kit ships no validators.
Form.Root reports unsaved changes to the page's leave guards (useLeaveGuard, or useRouterLeaveGuard from ./unsaved-changes/tanstack-router) while its leaveGuard policy returns true for the form's live state. The default, whenDirty, guards while the values differ from their defaults and no submit is in flight. Pass never for a form whose edits need not survive leaving, or any (state) => boolean:
import { never } from "@igne-agency/react-web-ui/unsaved-changes";
<Form.Root form={filters} leaveGuard={never}>
…
</Form.Root>;MIT