Web
Use Tanrei's CSS tokens and Web Components in any web project.
@tanrei/web contains two things: a stylesheet of design tokens as CSS custom properties, and standards-based custom elements built with Lit. It has no framework requirement.
Prerelease
0.1.0-next.0 is published to npm under the dist-tag next: until 0.1.0, install @tanrei/web@next. During the 0.x prereleases backward compatibility is not kept: any prerelease may break anything, with no deprecation period first, and its changeset says what broke.
npm install @tanrei/webTo use the source as it stands on main, before it is published, you need Git, Node 22.22 or later and pnpm 12 (corepack enable gives you the version the repository pins). Build the package in a clone of the repository, pack it, and install the packed file in your project:
git clone https://github.com/chnotchy/tanrei.git
cd tanrei
pnpm install
pnpm --filter @tanrei/web build
pnpm --filter @tanrei/web pack --pack-destination ../tanrei-packages# in your project: the path to the folder the packages were written to
npm install path/to/tanrei-packages/tanrei-web-*.tgzThe packed file holds what a release publishes, so everything below works the same way. To take a newer state of the source, pull, build and pack again, then install the new file.
-
Load the stylesheets once, as early as possible. They are wrapped in cascade layers (
tanrei.*), so your own styles always win.import '@tanrei/web/tanrei.css';tanrei.cssis everything in one file: the base of the page, the tokens, and the stylesheets of the parts drawn without an element (the text styles, the table, the accordion and the others). To load only what you use, take the tokens, the base if you want it, and each of those stylesheets as you need it:import '@tanrei/web/tokens.css'; import '@tanrei/web/base.css'; // the page in the system's surface, text color and body face, with no margin on the bodyThe base gives the page itself the surface color, your own text the on-surface color and the body face, and takes the browser’s margin off the body. Without it the page keeps the browser’s defaults.
-
Register the components of your design system, or only the elements you use.
import '@tanrei/web'; // on a preset, its entry instead: m3-like, m3-expressive-like or cupertino import '@tanrei/web/m3-like'; // or, one element at a time: import '@tanrei/web/components/button';@tanrei/webregisters the components the default system ships and no other, so a product on it cannot reach one its system does not have. A preset’s entry,@tanrei/web/<id>, registers what that preset ships, and@tanrei/web/allevery component of the package. A component that only a preset ships, such as the segmented button, can still be taken on purpose: include it in your system’s layer and import its own module.The
@tanrei/web/…specifiers are package exports: a bundler (Vite, webpack, esbuild) resolves them. Without one, a browser cannot resolve a bare specifier in<link>or<script src>, so serve the package’sdist/and point at the files — the stylesheets as they are, andtanrei.bundle.js, which carries Lit and every element:<link rel="stylesheet" href="/node_modules/@tanrei/web/dist/tanrei.css" /> <script type="module" src="/node_modules/@tanrei/web/dist/tanrei.bundle.js"></script> -
Use the elements like any other HTML.
<tanrei-button variant="filled">Deploy</tanrei-button> <tanrei-button variant="outlined" size="small" disabled>Cancel</tanrei-button> -
Give your product’s words. A component has none of its own: the text of a requirement tag, the name of a close button and the like are your product’s. To start with plain English for every one of them, import the English word pack once:
import '@tanrei/web/words/en';Change any of them, or give the words of another language, with
setWords()after it. A word you give wins over the pack, and a word on an element wins over both.import { setWords } from '@tanrei/web'; setWords({ requiredText: 'Mandatory' }); setWords({ requiredText: '必須', optionalText: '任意', closeLabel: '閉じる' }, { lang: 'ja' });Without the pack and without your words, a component still works, and tells you in the console which word it lacks. See Words.
Live
Editors and coding agents read the package’s elements from its
custom elements manifest, @tanrei/web/custom-elements.json: every element with its attributes, properties, events, slots and parts, and nothing a product cannot reach. @tanrei/web/custom-elements/<id>.json holds only the elements system <id> ships, so a tool that works in one system offers nothing else.
Token values use light-dark(), so they follow the color-scheme of the element they are used on. Out of the box that means the operating system setting, and prefers-contrast: more switches to the high-contrast scheme.
To take control, set attributes:
<!-- Force a theme for the page or for any subtree -->
<html data-tanrei-theme="dark">
<!-- Force a contrast level for the page: "medium" or "high" -->
<html data-tanrei-contrast="high">Note
Both attributes work on <html> or on any subtree. The generated stylesheet declares the whole token set on :root, [data-tanrei-contrast], so a data-tanrei-contrast on a nested element re-resolves the component tokens inside it against that contrast level, not only the system colors.
If people choose a theme in your product, store the choice and put it on <html> before the page is first drawn: set by a script that runs later, the page shows the system’s theme for a frame first. @tanrei/web/theme-script does both. themeScript() is a small script to inline in <head>, before the stylesheets, and saveTheme() stores a choice and draws it at once.
import { loadTheme, saveTheme } from '@tanrei/web/theme-script';
saveTheme('dark'); // 'light', 'dark', or 'system' to follow the operating system again
loadTheme(); // what is stored: 'light', 'dark' or 'system'In Next.js (App Router), inline it in the root layout. suppressHydrationWarning is there because the script changes an attribute of <html> before React hydrates it.
// app/layout.tsx
import { themeScript } from '@tanrei/web/theme-script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>{children}</body>
</html>
);
}In Astro, inline it in the layout:
---
// src/layouts/Layout.astro
import { themeScript } from '@tanrei/web/theme-script';
---
<html lang="en">
<head>
<script is:inline set:html={themeScript()} />
</head>
<body><slot /></body>
</html>Every token is a custom property named after its path: tanrei.sys.color.primary becomes --tanrei-sys-color-primary.
.panel {
background: var(--tanrei-sys-color-surface-container-low);
border-radius: var(--tanrei-sys-shape-corner-medium);
box-shadow: var(--tanrei-sys-elevation-level1);
font: var(--tanrei-sys-typescale-body-font);
}To set a token from script, use setTokens() from @tanrei/web: setTokens({ '--tanrei-sys-color-primary': '#0b57d0' }) on the root, or with { on: element } for one part of the page. One token set alone on the root with style.setProperty() makes Safari restyle the whole page for seconds; setTokens() writes it so that it does not.
Text styles are named by level (heading-2 is an h2, body a p, label the text on a control), and each one is a set of custom properties: --tanrei-sys-typescale-<role>-font as a shorthand, plus -font-family, -font-size, -font-weight, -line-height, -letter-spacing and -font-weight-emphasized. Two optional stylesheets put them on elements for you:
import '@tanrei/web/text.css'; // .tanrei-text-<role>, .tanrei-text-emphasized
import '@tanrei/web/prose.css'; // h1–h6, p, small, code, pre inside .tanrei-prose<h2 class="tanrei-text-heading-2">Section</h2>
<p class="tanrei-text-body">Running text with <strong class="tanrei-text-emphasized">stress</strong>.</p>
<article class="tanrei-prose"><!-- rendered Markdown --></article>.tanrei-text-emphasized draws the role it sits in at its weight plus the family’s emphasis step; it is a weight, not another role. The sixteen roles and the element mapping are on Typography.
The typescale asks for Inter, Noto Sans JP, Noto Sans and Roboto Mono (--tanrei-ref-typeface-{brand,plain,mono} in tokens.css; tanrei build prints the list for any system), icons for Material Symbols Rounded, and everything falls back to system fonts. Tanrei does not bundle font files; load them yourself under exactly those family names — from Google Fonts, or from the @fontsource-variable/* packages (which register Inter Variable and so on: declare your own @font-face with the plain name pointing at their files, as this site does).
import '@tanrei/web/icons.css';<tanrei-button><span slot="icon" class="tanrei-icon" aria-hidden="true">add</span>Add</tanrei-button>.tanrei-icon draws a Material Symbols name with the system’s icon weight and takes its size from where it sits. You never choose outlined or filled: see Icons.
The whole Material Symbols Rounded file holds every glyph at every weight, fill, grade and optical size: 5.4 MB, which a page on a slow line can wait half a minute for. Load only the glyphs your product draws. tanrei build prints the names Tanrei’s components may draw themselves (a chevron, a check, the cross of a close button…) and the axis values your system draws icons at; add your own names to that list. From Google Fonts, pass the names, sorted, as icon_names= and the axes as ranges in family= (Material+Symbols+Rounded:opsz,wght,FILL,[email protected],300..500,0..1,0 for the default system). Serving the file yourself, cut it down with a subsetter that keeps the font’s ligature features (rlig, rclt) and both fills; this site draws its icons from such a file. Either way, register the icon face with font-display: block: until the file is there an icon is invisible, never its name in letters.
Tanrei works in these versions and later:
| Browser | Version |
|---|---|
| Chrome and Edge | 125 |
| Firefox | 126 |
| Safari, on macOS and iOS | 17.5 |
Each number is the newest of the features Tanrei cannot draw without, none of which has a fallback:
- CSS. Every color is a
light-dark()pair (Chrome and Edge 123, Firefox 120, Safari 17.5). Sizes and type sizes are rounded with CSSround()(Chrome and Edge 125, Firefox 118, Safari 15.4). The components restyle on custom states,:state()(Chrome and Edge 125, Firefox 126, Safari 17.4). Menus, tooltips and snackbars open in the top layer with the Popover API (Chrome and Edge 114, Firefox 125, Safari 17). The cascade layers the stylesheets are wrapped in came earlier (Chrome and Edge 99, Firefox 97, Safari 15.4). - JavaScript. The package is ES2022, and its build fails if any file it publishes goes further. Its newest syntax, class static blocks, runs from Chrome and Edge 94, Firefox 93 and Safari 16.4. Compiling the package for an older target does not lower the floor: the CSS above stays what it is.
A few newer features are used only where a browser without them still works: without @starting-style an opening animation does not play, and without corner-shape a corner stays round.
Tanrei does not do this itself: it belongs to your product’s entry point. Test the features first, and load the app, and Tanrei with it, only when they are there:
<p id="unsupported" hidden>This site needs Chrome or Edge 125, Firefox 126 or Safari 17.5, or a later version.</p>
<script type="module">
// light-dark() sets Safari's floor, round() Chrome's and Edge's, :state() Firefox's.
const supported =
CSS.supports('color', 'light-dark(red, red)') &&
CSS.supports('width', 'round(1px, 1px)') &&
CSS.supports('selector(:state(open))');
if (supported) import('./app.js'); // your app, which imports @tanrei/web
else document.getElementById('unsupported').hidden = false;
</script>Keep this script small and plain: every visitor’s browser runs it, the old ones included. With a static import, the browser loads the app before this script runs, and an old one that cannot parse the app never reaches the test; import() loads it only afterwards.
Tanrei’s elements are built on what browsers implement: shadow roots, ElementInternals (a form control’s value and validity, custom states), the Popover API, <dialog> and layout. jsdom and happy-dom implement only part of that (happy-dom has neither attachInternals() nor the Popover API), so an element can throw when it renders, leave a rejected promise behind, or render without the sizes, focus order and top layer it relies on. Tanrei does not support them, and ships no stand-ins for what they lack: a stand-in that keeps an element from throwing would let a test pass on behaviour no browser has.
Test whatever renders a Tanrei element in a real browser:
Vitest’s browser mode, with its Playwright provider, keeps your component tests in Vitest and runs them in Chromium, Firefox or WebKit.- Playwright Test runs pages and whole flows.
Tests of your own logic that render no Tanrei element can stay in Node, jsdom or happy-dom. Tanrei’s own tests run in Chromium, Firefox and WebKit, with axe-core in Playwright.
Most elements put their role on a native element inside their shadow root (a <button>, an <input>, a <dialog>) rather than on themselves, so that browsers and testing tools see real semantics; a few, such as a tab or an open menu, carry it themselves. Playwright’s locators look inside open shadow roots, so find a control by its role and name, as a person would:
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('switch', { name: 'Wi-Fi' })).toBeChecked();A click on that button reaches the listeners on <tanrei-button> itself. Testing Library’s queries do not look inside shadow roots, so they do not find these controls.
Some elements keep a role in their shadow root while what belongs to it is your own markup, placed into it through a slot: the tab list of <tanrei-tabs> and its <tanrei-tab> children, the <dialog> of <tanrei-dialog> and its actions. In the DOM the tabs are not inside the tab list, so a locator chained from the role finds nothing:
// Finds nothing: the tabs are children of <tanrei-tabs>, not of its tab list.
page.getByRole('tablist', { name: 'Sections' }).getByRole('tab', { name: 'Billing' });Find the element that holds the named role, then search inside it:
const sections = page.locator('tanrei-tabs', { has: page.getByRole('tablist', { name: 'Sections' }) });
await sections.getByRole('tab', { name: 'Billing' }).click();
const confirm = page.locator('tanrei-dialog', { has: page.getByRole('dialog', { name: 'Delete the file?' }) });
await confirm.getByRole('button', { name: 'Delete' }).click();While it is open, <tanrei-menu> is the menu, named by its anchor, but it takes no room in the page: its surface is drawn in the top layer. A visibility check on the menu therefore fails even while it is open. Check that the menu is there, then check its items:
await page.getByRole('button', { name: 'More' }).click();
const menu = page.getByRole('menu', { name: 'More' });
await expect(menu).toBeAttached();
await expect(menu.getByRole('menuitem', { name: 'Rename' })).toBeVisible();When it closes, the element is no longer a menu, and await expect(menu).toHaveCount(0) passes.
In a development build, an element that is used in a way its spec does not allow says so in the console, in a message that starts with [tanrei] and ends with the id of the behaviour. A browser test can fail on any of them: the guide for AI agents has a Playwright example.