On this page
Set up with your agent
Paste this into your coding agent, or follow the steps below by hand.
Set up envUI in my app: envui.envsemble.com/ SKILL.mdA coding agent can do this setup with you, following the setup skill the site hosts: it shows its plan and waits for your yes, adds a pointer to these docs to the project's AGENTS.md and turns on the lint rules.
envUI is a package of React components and design tokens for React and React DOM 19. Install an exact version and read the docs shipped with it.
Registry access
envUI is published to GitHub Packages for now, so npm needs its registry and a token:
# Project .npmrc
@envsemble:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
Locally, NODE_AUTH_TOKEN is a classic GitHub token with read:packages; in GitHub Actions, the repository's GITHUB_TOKEN with packages: read, once it has Read access to the package. Keep real tokens out of committed files.
Install a release
npm install --save-exact @envsemble/envui@1.0.0-prerelease.5
Upgrade @envsemble/envui and the lint packages together, to the same exact version.
Connect styles and theme
import { Theme, Button } from '@envsemble/envui';
import { IconAdd } from '@envsemble/envui/icons/Add';
export function App() {
return (
<Theme theme="light">
<Button startIcon={<IconAdd />}>Create item</Button>
</Theme>
);
}
Put the UI inside Theme: a theme attribute on the document alone gives no React context. The styles include Geist and sit in the env cascade layer: cascade layers says how the application's CSS and resets take their place around it, and colors and themes covers nested themes, portals and high contrast.
Styles come with the components
Each component's module imports its own style sheet, after the base every component shares: the layer order, the tokens, the theme and the font. A bundler that loads CSS from dependencies, such as Vite, Next.js, webpack with a CSS loader or esbuild, puts in the page what the application's modules import, so the page loads the CSS of the components it uses.
- Without a bundler, import
@envsemble/envui/styles.cssonce, in the client entry: it holds every component's styles. Don't import it as well as the components' own: the styles would load twice. - In Node, as when a server renders or tests run without a bundler, the same modules load without their CSS: the package's style sheet imports resolve to an empty module under the
nodecondition, and only a bundler, which sets themodulecondition, loads the CSS. Server-rendered pages get their CSS from the client bundle, or from the framework that collects it, as Next.js does. - Next.js with webpack keeps only the style sheets a page uses when
experimental.optimizePackageImportslists@envsemble/envui.
Theme preference
For a whole application, ThemePreferenceProvider replaces the fixed Theme. It follows the system theme until the user picks one with ThemeToggle, stores that choice, applies a choice made in another window of the same origin, and themes the document root, so the page background, native controls, other libraries' portals and <meta name="theme-color"> match.
- Type. The root keeps the browser's font size, so 1 rem stays the reader's. To change envUI's typeface, size or line height, set
--env-font-family,--env-font-size-bodyand--env-line-height-bodyfor.env-themein a style sheet loaded after envUI's, not in abodyrule. - Contrast and scale.
contrastishigh,standardorsystem. Left out, the provider takes a surroundingTheme's, and else followsprefers-contrast: more, keeping adata-env-contrastthe page set on the root;scaledoes the same, elsesystem: compact under a fine pointer, comfortable on touch (Spacing & density). The provider marks the contrast it writes withdata-env-contrast-source; a page that keeps a contrast of its own on the root removes that attribute. - One per document. A second provider, such as for a section with a preference of its own, passes
applyToDocument={false}; envUI warns in development about two. Unmounted, a provider removes the contrast and scale it wrote.
import { ThemePreferenceProvider, ThemeToggle } from '@envsemble/envui';
<ThemePreferenceProvider storageKey="app-theme">
<ThemeToggle />
</ThemePreferenceProvider>;
Add the bootstrap script to the document head so a saved dark theme does not flash light while the application loads. createThemeBootstrapScript from @envsemble/envui/theme-bootstrap returns the inline script; use the same storage key as the provider:
// vite.config.ts
import { createThemeBootstrapScript } from '@envsemble/envui/theme-bootstrap';
const themeBootstrap = {
name: 'theme-bootstrap',
transformIndexHtml: () => [
{
tag: 'script',
children: createThemeBootstrapScript({ storageKey: 'app-theme' }),
injectTo: 'head-prepend',
},
],
};
An application that renders <html> with React adds suppressHydrationWarning to it, since the script sets the root's attributes before React hydrates:
<html lang="en" suppressHydrationWarning>
Where the application's contrast is fixed, pass it to both, contrast: 'high' to the script and contrast="high" to the provider, so the first paint has it. Where it is a setting the application stores, leave it out of the script: the provider applies it as the application starts.
Connect your router
Destinations in envUI render native anchors: Button and Link with href, NavigationBar, NavigationRail and NavigationDrawer destinations, Card, Pager, Breadcrumbs and Menu.Item. Register the router's link once with LinkProvider, around the application, so they navigate on the client. The component gets native anchor props with a string href, and renders an anchor with them and the ref:
<LinkProvider component={RouterLink}>{app}</LinkProvider>
Each recipe is a client module of the application's own: a RouterLink and a RouterLinks provider to render around the application.
TanStack Router
Paths inside the application go through the router's Link; other addresses, such as mailto:, stay plain anchors. TanStack types to by the registered routes, so the recipe asserts the path's type, and the search reaches each route as strings, a repeated key as an array, for its validateSearch to parse.
import type { ReactNode } from 'react';
import { Link, type LinkProps } from '@tanstack/react-router';
import { LinkProvider, type LinkComponentProps } from '@envsemble/envui';
function RouterLink({ href, ...props }: LinkComponentProps) {
if (!href.startsWith('/') || href.startsWith('//')) return <a href={href} {...props} />;
// Any origin will do: it only splits the path, the search and the hash.
const url = new URL(href, 'https://application.invalid');
// A key given more than once, such as ?tag=a&tag=b, keeps all its values.
const search: Record<string, string | string[]> = {};
for (const key of new Set(url.searchParams.keys())) {
const values = url.searchParams.getAll(key);
search[key] = values.length === 1 ? values[0]! : values;
}
return (
<Link
{...props}
to={url.pathname as LinkProps['to']}
search={search}
hash={url.hash.slice(1) || undefined}
/>
);
}
export function RouterLinks({ children }: { children: ReactNode }) {
return <LinkProvider component={RouterLink}>{children}</LinkProvider>;
}
React Router
React Router's Link renders addresses outside the application as plain anchors itself.
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { LinkProvider, type LinkComponentProps } from '@envsemble/envui';
function RouterLink({ href, ...props }: LinkComponentProps) {
return <Link to={href} {...props} />;
}
export function RouterLinks({ children }: { children: ReactNode }) {
return <LinkProvider component={RouterLink}>{children}</LinkProvider>;
}
Next.js App Router
next/link takes the anchor's props and prefetches the destinations in view. The provider is a client component, so it goes in a module with 'use client', which the root layout renders around its children.
'use client';
import type { ReactNode } from 'react';
import NextLink from 'next/link';
import { LinkProvider, type LinkComponentProps } from '@envsemble/envui';
function RouterLink(props: LinkComponentProps) {
return <NextLink {...props} />;
}
export function RouterLinks({ children }: { children: ReactNode }) {
return <LinkProvider component={RouterLink}>{children}</LinkProvider>;
}
A router on the History API
A router on history.pushState takes a plain press on a link to one of its pages and leaves the browser the rest: modifier keys, other buttons, target, download and other origins. navigate is the router's own function.
import type { ReactNode } from 'react';
import { LinkProvider, type LinkComponentProps } from '@envsemble/envui';
declare function navigate(path: string): void;
function HistoryLink({ onClick, ...props }: LinkComponentProps) {
return (
<a
{...props}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if ((props.target && props.target !== '_self') || props.download !== undefined) return;
const url = new URL(props.href, location.href);
if (url.origin !== location.origin) return;
event.preventDefault();
navigate(url.pathname + url.search + url.hash);
}}
/>
);
}
export function RouterLinks({ children }: { children: ReactNode }) {
return <LinkProvider component={HistoryLink}>{children}</LinkProvider>;
}
Locale, strings and direction
envUI works without setup in English, in the runtime's locale and in each element's direction. To translate or format for its people, an application wraps itself or a region in EnvUIProvider, here in a client component, where t is its translation function:
'use client';
import { EnvUIProvider } from '@envsemble/envui';
import type { ReactNode } from 'react';
export function Localized({ children }: { children: ReactNode }) {
return (
<EnvUIProvider
locale="de-CH"
messages={{
common: { loading: 'Wird geladen' },
combobox: { clear: 'Leeren' },
toast: { hidden: ({ count }) => t('notifications.hidden', { count }) },
}}
dir="ltr"
>
{children}
</EnvUIProvider>
);
}
localeis the BCP 47 tag envUI formats numbers, dates and relative times in. Without it, the server and the first client render useen-GB, so their markup matches, and the browser's locale applies once the page has hydrated.useLocale()returns the provider's locale, to format in step with envUI.messagesoverrides envUI's built-in strings, per namespace, one per component family, such ascommon,toastorpagination. A message is a string, or a function of the values it names, so any translation library plugs in. Nested providers merge per namespace, the inner winning, and a component's own label prop, such ascloseLabel, wins over both.defaultMessagesfrom@envsemble/envui/messageslists every namespace and key with its English default, for extracting them.dirsets the direction for keyboard and placement logic, such as which arrow key moves forward. Without it, components read the direction at their element. It doesn't set thedirattribute: set that on the page or the region.
The provider is a client component: a server component can render it with string messages, but function messages, which can't cross to the client, come from a client component of the application's own, as above. The provider compares messages per namespace, so an inline object costs nothing while its strings and functions stay the same; define message functions once, outside the component or with useMemo.
Render overlays elsewhere
Popups, menus, tooltips, dialogs, sheets and toasts render in a portal at the end of the body. PortalContainer renders them into another element instead, such as one in a shadow root or an iframe, where its styles reach them:
'use client';
import { PortalContainer } from '@envsemble/envui';
import { useState, type ReactNode } from 'react';
export function Preview({ children }: { children: ReactNode }) {
const [layer, setLayer] = useState<HTMLDivElement | null>(null);
return (
<div style={{ position: 'relative', contain: 'layout', containerType: 'inline-size' }}>
<PortalContainer container={layer} contained>
{children}
</PortalContainer>
<div ref={setLayer} />
</div>
);
}
containeris the element they render into. Passnullwhile it mounts, as above: they wait for it.containedkeeps them inside the container, for a preview with its overlays open, such asdefaultOpenon a Menu.Root: nothing locks scroll, traps or takes focus, or hides the page; popups open on their own side at their content's size; and a press or Esc elsewhere doesn't close them.- Give the container layout containment, as above, so dialogs, sheets and toasts sit inside it, and it or an ancestor
container-type: inline-size, which bounds a popup's width. Make itinertwhere it is only a picture.
React Server Components
envUI works with React Server Components, from the same entry points, which carry no directive:
- Components with state, effects, context or Base UI interactions are client components, with
'use client':Theme,Dialog,Menu.Rootand its parts,Table.Root, the form fields, and icons that follow a selection's fill. - Components that only lay out markup, such as
Card,Badge,Text,Button,AlertandList.Item, render on the server and send no code. - Plain functions, such as
createThemeBootstrapScriptandformatRelativeTime, run on the server.
A server component passes a client component only serializable values, no functions: event handlers, render props, formatting callbacks and hooks such as useTheme and useToast belong in a client component of the application's own.
Public entry points
@envsemble/envuifor ordinary UI.@envsemble/envui/icons/<Name>for individual official symbols, eachIcon<Name>.@envsemble/envui/codewhen the feature shows code: Code and CodeBlock.@envsemble/envui/markdownwhen the feature shows or edits Markdown: Markdown and MarkdownEditor.@envsemble/envui/diffwhen the feature shows code diffs.@envsemble/envui/rich-textwhen the feature needs formatted editing or rendering.@envsemble/envui/colorwhen the feature chooses or shows colors: ColorPicker, ColorPickerButton and ColorSwatch.@envsemble/envui/messagesfordefaultMessages, envUI's English strings by namespace, for applications that extract or translate them.@envsemble/envui/devfor dev mode, in development only: load it with a dynamicimport()behind a development check, so production builds contain none of it.@envsemble/envui/assets/*for raw assets, such as the Geist font file.
The code, markdown, rich-text, diff and color entries need libraries that envUI doesn't list as dependencies, so an application that uses only the main entry never installs them. One that imports an entry installs its libraries, in these versions or later ones of the same major:
-
@envsemble/envui/code:npm install shiki@^4.5.0 @shikijs/langs@^4.5.0 -
@envsemble/envui/markdown:npm install shiki@^4.5.0 @shikijs/langs@^4.5.0 react-markdown@^10.1.0 remark-gfm@^4.0.1 -
@envsemble/envui/rich-text:npm install @tiptap/core@^3.31.4 @tiptap/extension-image@^3.31.4 @tiptap/extension-list@^3.31.4 @tiptap/extension-mention@^3.31.4 @tiptap/extension-placeholder@^3.31.4 @tiptap/extension-table@^3.31.4 @tiptap/markdown@^3.31.4 @tiptap/pm@^3.31.4 @tiptap/react@^3.31.4 @tiptap/starter-kit@^3.31.4 @tiptap/static-renderer@^3.31.4 @tiptap/suggestion@^3.31.4 marked@^17.0.6 shiki@^4.5.0 @shikijs/langs@^4.5.0 -
@envsemble/envui/diff:npm install @pierre/diffs@^1.5.2 shiki@^4.5.0 -
@envsemble/envui/color:npm install @react-aria/color@^3.2.1 @react-stately/color@^3.10.1 @react-aria/i18n@^3.13.1React Aria's names of channels and colors in every language are about 57 KB of the entry's 190 KB minified;
@react-aria/optimize-locales-pluginkeeps only the application's languages.
They aren't peer dependencies, since GitHub Packages drops peerDependenciesMeta; a missing one fails the build where the entry is imported.
Import each icon by its own path, such as import { IconAdd } from '@envsemble/envui/icons/Add'. See icons.
Bundle size
The modules have no side effects, so a bundler keeps only what the application uses; Vite keeps only the parts of a namespace the application renders.
Lint rules
@envsemble/eslint-plugin-envui and @envsemble/stylelint-config-envui report hand-built controls and raw colors, naming the envUI component or token to use, so people and agents see them as they edit and check. Install them at envUI's version; each one's README says how to turn it on:
npm install --save-dev --save-exact @envsemble/eslint-plugin-envui@1.0.0-prerelease.5 @envsemble/stylelint-config-envui@1.0.0-prerelease.5