# Youj documentation > Youj: Accessible components, animated icons, illustrations and themes that move together. It is a React UI kit with accessible components on shadcn and Base UI (`@youj/react`), motion tokens, primitives, animated icons that morph between states and brand marks (`@youj/motion`, `@youj/icons`), opt-in interface sounds the components cue as they move (`@youj/sound`), themes with contrast checked against WCAG AA, isometric illustrations drawn from the theme (`@youj/illustrations`), and the same source to copy in with `npx @youj/cli add`. It needs React 19 and Tailwind CSS v4. Agents: install with `npx @youj/cli add ` (or the packages), never `npx shadcn add`. ## Which import path | | From the packages | Copied in with `npx @youj/cli add` | | --- | --- | --- | | Components | `import { Button } from '@youj/react'` | `import { Button } from '@/components/ui/button'` | | Motion | `import { Reveal } from '@youj/motion'` | `import { Reveal } from '@/components/youj/motion/reveal'` | | Provider and themes | `import { YoujProvider, basalt } from '@youj/core'` | `import { YoujProvider } from '@/lib/youj/provider'` and `import { basalt } from '@/lib/youj/theme'` | # Introduction > Accessible components, animated icons, illustrations and themes that move together. Source: https://www.youj.dev/docs ```tsx 'use client' import { useEffect, useId, useState } from 'react' import { useYouj } from '@youj/core' import { CheckIcon, FileImageIcon, FileSpreadsheetIcon, FileTextIcon } from '@youj/icons' import { IllustrationCloud, IllustrationSuccess } from '@youj/illustrations' import { ContentSwap } from '@youj/motion' import { Button, DialogFooter, DialogHeader, Progress } from '@youj/react' // Each file starts and finishes at its own time (ms), like a real batch. const FILES = [ { name: 'brand-guide.pdf', size: '2.4 MB', icon: FileTextIcon, start: 0, end: 2100 }, { name: 'hero-photo.png', size: '1.8 MB', icon: FileImageIcon, start: 250, end: 2900 }, { name: 'q3-budget.xlsx', size: '640 KB', icon: FileSpreadsheetIcon, start: 500, end: 1600 }, ] const DONE = Math.max(...FILES.map((f) => f.end)) // A stand-in for the upload: progress by time, eased so each file slows as it lands. function progress(file: (typeof FILES)[number], t: number) { const x = Math.min(1, Math.max(0, (t - file.start) / (file.end - file.start))) return Math.round((1 - (1 - x) ** 1.6) * 100) } // Shown in place, as an open dialog resting in the page. In an app it is a // DialogContent opened by your upload button, with these same parts inside. export default function IntroUploadDialog() { const { reduced } = useYouj() const id = useId() const [t, setT] = useState(reduced ? DONE : 0) useEffect(() => { if (reduced) return setT(DONE) const from = performance.now() const timer = setInterval(() => { const now = performance.now() - from setT(Math.min(now, DONE)) if (now >= DONE) clearInterval(timer) }, 120) return () => clearInterval(timer) }, [reduced]) const finished = FILES.filter((f) => progress(f, t) === 100).length const done = finished === FILES.length return (
} className="w-full">

Upload 3 files

{done ? 'Added to Orion / Assets' : `Uploading to Orion · ${finished} of ${FILES.length}`}

) } ``` Youj is accessible components, animated icons, illustrations and themes that move together. Use it to build React interfaces. One provider sets the theme, speed and reduced motion for all of them. > **Alpha:** Youj is in alpha (0.x): APIs can change between minor versions. What that promises. ## Quick start You need Node 22 or later and a React 19 app. Don't have one yet? Run `npx create-next-app@latest my-app --ts --tailwind --app`, then `cd my-app` (or `npm create vite@latest my-app -- --template react-ts` for Vite). 1. In your app, run `npx @youj/cli init`. It installs the packages and wires the provider and the stylesheet. 2. Import a component and render it: ```tsx import { Button } from '@youj/react' export default function Page() { return } ``` 3. Start your dev server. A Youj button appears on the page. In the Next.js App Router, a component that takes an event handler or holds state needs `'use client'` at the top of its file. A plain ` ``` ## Docs for any model Every page is also plain Markdown, for tools without MCP: - [/llms.txt](https://www.youj.dev/llms.txt): an index of the docs and blocks, in the [llms.txt](https://llmstxt.org) format. - [/llms-small.txt](https://www.youj.dev/llms-small.txt): every component's import, one-line description and main props. - [/llms-full.txt](https://www.youj.dev/llms-full.txt): every docs page in one file. - Any page's URL plus `.md`, such as [/docs/components/button.md](https://www.youj.dev/docs/components/button.md): that page, with its examples, props and motion tokens as text. ## Run it yourself The hosted server can't see your project. Run the server on your machine when you want your tool to inspect the app you're working in, or to work offline: - **Your project.** Only the local server has `get_project_info`, and its `get_add_command` writes the command for your package manager and install route (the hosted one shows npm and says to use your own). - **Your version.** The local server answers from an index bundled with the package, so it describes exactly the Youj version it ships with. Pin it to your app's version and the answers match what you have installed. - **Offline.** Once it's installed, it needs no network. The quickest way is the CLI, run in your app: ```bash npx @youj/cli mcp ``` It asks which editor, writes that editor's config file and pins the server to the Youj version your app has installed. `--client` picks the editor without asking, and `--print` prints the config instead of writing it. The [CLI reference](https://www.youj.dev/docs/cli#mcp) lists every option. To add it by hand, put this in your editor's config: **Claude Code**: `npx @youj/cli mcp --client claude`, or `claude mcp add youj -- npx -y @youj/mcp@latest`, or add to `.mcp.json`: ```json { "mcpServers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **Cursor**: `npx @youj/cli mcp --client cursor`, or add to `.cursor/mcp.json`: ```json { "mcpServers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **VS Code**: `npx @youj/cli mcp --client vscode`, or add to `.vscode/mcp.json`: ```json { "servers": { "youj": { "type": "stdio", "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **Windsurf**: `npx @youj/cli mcp --client windsurf`, or add to `~/.codeium/windsurf/mcp_config.json`: ```json { "mcpServers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **Codex**: `npx @youj/cli mcp --client codex`, or `codex mcp add youj -- npx -y @youj/mcp@latest`, or add to `.codex/config.toml`: ```toml [mcp_servers.youj] command = "npx" args = ["-y", "@youj/mcp@latest"] ``` **Gemini CLI**: `npx @youj/cli mcp --client gemini`, or add to `.gemini/settings.json`: ```json { "mcpServers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **Zed**: `npx @youj/cli mcp --client zed`, or add to `.zed/settings.json`: ```json { "context_servers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` **JetBrains**: `npx @youj/cli mcp --client jetbrains`, or paste in Settings | Tools | AI Assistant | Model Context Protocol (MCP): ```json { "mcpServers": { "youj": { "command": "npx", "args": ["-y", "@youj/mcp@latest"] } } } ``` The snippets use `@latest`. To match an app on a fixed version, pin the server to it, as the CLI does: `@youj/mcp@`. To run the server directly over stdio, for another MCP client or to inspect it: ```bash npx -y @youj/mcp ``` It needs Node.js 22 or later. When the app in the server's working folder has a different Youj version from the server, the server says so in its instructions and names the matching server to run. ## Tools Every tool is read-only: none of them writes files or installs anything. They return Markdown sized for a model's context. A name the index doesn't know comes back as an error. ### Find **`search`**: searches by meaning across components, blocks, icons, illustration scenes, loaders (`npx @youj/cli add loader-`), hooks and docs, and gives each result the tool to call next and the exact way in: the package import, or `npx @youj/cli add` for what's copied in. | Input | Type | Default | Description | | --- | --- | --- | --- | | `query` | string | required | What you need, in plain words: "loading spinner", "delete confirmation", "settings page". | | `kinds` | `component`, `block`, `icon`, `illustration`, `miniature`, `loader`, `hook`, `docs` (list) | all | Only these kinds. | | `limit` | 1 to 25 | `8` | How many results. | **`get_component`**: everything needed to use one component, motion piece, illustration scene (`illustration-coffee`) or the illustration kit: what it's for and what to use instead, install, usage, props, motion tokens, reduced motion and accessibility. | Input | Type | Default | Description | | --- | --- | --- | --- | | `name` | string | required | Registry name (`alert-dialog`), title or export (`AlertDialogContent`). | | `part` | string | | A sub-component export to show props for, such as `DialogContent`. | | `sections` | `install`, `usage`, `props`, `motion`, `a11y`, `related` (list) | all | Only these sections. | **`list_components`**: every component (UI, core, motion, sound) by docs category, with its package, stability and a one-line summary. Optional `kind` (`ui`, `core`, `motion` or `sound`) and `category` (such as `"forms"`). **`list_loaders`**: every loading animation by category, with its id, component and technique. Optional `category`. Install one with `npx @youj/cli add loader-`. **`list_miniatures`**: every miniature (an interactive isometric object) by category, with its id and component. Optional `category`. ### Icons **`search_icons`**: Youj icons, brand marks and, optionally, Lucide icons by meaning, with synonyms, typos and prefixes. Returns names, a JSX snippet each and the import line. | Input | Type | Default | Description | | --- | --- | --- | --- | | `query` | string | required | "notification", "delete", "loading". | | `type` | `all`, `static`, `morph`, `swap` | `all` | `static` icons have one pose and animate on hover. `morph` icons morph between two meanings. `swap` lists IconSwap pairs. | | `category` | a Youj icon category | | Only this category. | | `source` | `all`, `youj`, `lucide` | `all` | Which set. | | `limit` | 1 to 30 | `12` | How many results. | **`get_icon`**: one icon in detail: its choreography, its states (for a morphing icon), its triggers, the accessible button pattern, props, install and IconSwap partners. For a Lucide icon, how to animate it with `YoujIcon`. | Input | Type | Description | | --- | --- | --- | | `name` | string | `bell-off`, `BellOffIcon` or `lucide:rocket`. | **`suggest_icon_morph`**: the best way to animate between two meanings. Returns the state icon made for the pair when one exists, otherwise an IconSwap between the two best icons with the transition that fits, plus the accessible button code. | Input | Type | Description | | --- | --- | --- | | `pair` | string | `"from → to"`, such as `"play → pause"`. | | `from`, `to` | string | The two meanings, instead of `pair`. | ### Blocks Blocks are the copy-in [levels](https://www.youj.dev/docs/choosing-a-level) card, section and screen (an app frame is built in [Create](https://www.youj.dev/create)). Results name the level. **`list_blocks`**: every block, grouped by domain and tagged with its level, with a one-line summary. Optional `group` (such as `"Auth"`) lists one group, and `level` (`card`, `section` or `screen`) lists one level. **`get_block`**: one block: its description, the components it uses, its install command and its files as `npx @youj/cli add` writes them. | Input | Type | Description | | --- | --- | --- | | `slug` | string | The block, such as `app-auth-flow`. | | `source` | boolean | Returns the source code instead of the summary. | | `file` | string | Which file, from the summary's file list. | | `offset` | number | Character offset, to page through a long file. Source comes 12,000 characters at a time. | ### Illustrations **`list_illustrations`**: the ready-made isometric scenes by category, with their ids and tags. Without `category`, every category and its scene ids. Read one scene with `get_component("illustration-")`. **`get_illustration_guide`**: how to compose a new scene in the [illustration builder](https://www.youj.dev/illustrations/build)'s JSON format: the volume and grid, every part type, preset, shade, variant and base, rules that keep a scene readable at small sizes, and worked examples. Takes no input. **`draw_illustration`**: checks a scene in that format. A valid scene comes back as a component for your project's install route, the file to write it to, the install command and a link that opens it in the builder. An invalid scene comes back with every problem and its fix. | Input | Type | Description | | --- | --- | --- | | `scene` | object or JSON string | `{ "name", "base", "parts": [...], "groups"?: [...] }`, as the guide describes. | | `name` | string | The component's name: "Empty inbox" becomes `IllustrationEmptyInbox`. Default: the scene's name. | | `cwd` | string | The app's folder, to pick the install route. | ### Tokens, themes and motion **`get_tokens`**: a preset's tokens: color (light and dark, with the class to use), type, radius, control size and control heights, motion (durations, easings, springs and staggers), and character (stroke, shadow, title and label voice, icon weight). | Input | Type | Default | Description | | --- | --- | --- | --- | | `preset` | `graphite`, `terminal`, `brutal`, `basalt`, `meadow`, `sandstone`, `cinnabar`, `umber`, `seaglass`, `heather` | `basalt` | Which preset. | | `groups` | `color`, `type`, `radius`, `spacing`, `motion`, `character` (list) | all | Only these groups. | | `mode` | `light`, `dark`, `both` | `both` | Which color mode. | **`create_theme`**: a theme from a brand color, built with `defineTheme` over a preset and checked with `checkTheme`: every text pair at least 4.5:1 and field borders at least 3:1 on every surface, in both modes. A theme that fails WCAG AA is refused unless `allowLowContrast` is set. Returns the code, how to apply it, the derived colors and any issues. | Input | Description | | --- | --- | | `accent` | Required. The brand color: hex, `rgb()`, `hsl()` or `oklch()`. | | `name` | The theme's name. | | `base` | The preset to extend. Default `basalt`. | | `neutral` | The neutral tint: `hue` (0 to 360), `chroma` (0 to 0.04), `wash` and `lift` (0 to 1; `lift` is how far raised surfaces stand above the page in dark mode). Default: toward the accent's hue. | | `success`, `warning`, `danger` | Status colors. | | `radius` | `none`, `sm`, `md`, `lg` or `full`. | | `controlSize` | Control heights: `compact`, `default` or `comfortable`. | | `motionPersonality` | The motion personality: `precise`, `calm` or `lively`. | | `fonts` | `sans`, `mono` and `display` font stacks. `display` is for titles and defaults to `sans`. | | `stroke` | `width` (border width in px, 0.5 to 4), `ink` (0, a quiet hairline, to 1, lines in the text color) and `icons` (icon stroke multiplier, 0.5 to 2). | | `shadow` | `soft` (blurred elevation), `hard` (solid offset shadows) or `none`. | | `actions` | What fills primary buttons: `accent` (default) or `ink`, the text color. | | `headings` | `weight` (100 to 900) and `tracking`. | | `labels` | For badges, column heads, field labels, tabs and keys: `case` (`none` or `upper`), `font` (`sans` or `mono`) and `tracking`. | | `allowLowContrast` | Accepts pairs that miss WCAG AA. | | `look` | The [look](https://www.youj.dev/docs/looks) to apply it with: `classic` (default) or `bezel`. | **`motion_guidance`**: names the primitive, preset or token that already does an interaction ("toast enters", "list reorders", "button success", "tabs indicator"), with a snippet, its [tokens](https://www.youj.dev/docs/motion/tokens) and its reduced-motion behavior. One input: `interaction`, in plain words. ### Your project These tools read the app on disk, so they're on the [local server](#run-it-yourself) only (the hosted one has `get_add_command` without the project). Reads stay inside `cwd`, including symlink targets, so packages hoisted above it aren't inspected. `cwd` defaults to the server's working folder. **`get_project_info`**: the framework, package manager, install route (the packages or the `@youj` registry), Youj, React and Tailwind versions, and setup warnings. Installed versions are labeled separately from the ranges in `package.json`. When an app has both routes, the registry wins. Optional input: `cwd`. **`get_add_command`**: the exact install command and imports for components, blocks or illustration scenes, for the project's route and package manager, including optional peers and the entry to import from. It leaves out dependencies the app already has. Blocks and scenes get an `npx @youj/cli add` command, which copies them in. Each name is answered on its own: an unknown one comes back as "Skipped" with suggestions beside the commands for the others (an error only when none is known). | Input | Type | Description | | --- | --- | --- | | `items` | list of names | 1 to 50 names, matched exactly, ignoring case. | | `cwd` | string | The app's folder. | **`setup`**: how to add Youj to an app: `npx @youj/cli init` and the manual steps for the framework and mode, in a component style when given. Paste `package.json` or the global stylesheet to check versions and stylesheet order, as [`youj doctor`](https://www.youj.dev/docs/cli#doctor) does. | Input | Description | | --- | --- | | `framework` | `next-app`, `next-pages`, `vite`, `react-router` or `astro`. | | `mode` | `package` or `registry`. | | `look` | `classic` (default) or `bezel`. | | `packageJson` | The app's `package.json`, as text. | | `css` | The global stylesheet, as text. | ## Resources | URI | What | | --- | --- | | `youj://docs/{slug}` | Every docs page as Markdown, such as `youj://docs/components/button`. The Introduction is `youj://docs/index`. | | `youj://components` | Every component, one line each. | | `youj://tokens` | Basalt's tokens: color, type, radius, control size and motion. | ## Prompts | Prompt | Arguments | What it does | | --- | --- | --- | | `build-screen` | `screen`, optional `framework` | Plans and builds a screen: starts from a block that fits, reads each component before using it, picks icons and morphs, and keeps to the tokens. | | `brand-theme` | `accent`, optional `name` | Creates a theme from a brand color, fixes what the contrast check reports, applies it app-wide and compares it with the closest preset. | ## The rules it follows The server tells your tool to import each thing from the package that owns it, to copy source in only with `npx @youj/cli add`, to use semantic color classes and Youj sizes rather than raw colors, to take timing from `useYouj()` or `var(--yj-duration-*)` rather than hard-coded durations, and to give every animation a reduced-motion equivalent. ## Related - [CLI](https://www.youj.dev/docs/cli): `npx @youj/cli mcp` and the other commands. - [Installation](https://www.youj.dev/docs/installation): Set up Youj with one command, or by hand. - [Icons](https://www.youj.dev/docs/icons): The animated set, its triggers and its morphs. - [Theming](https://www.youj.dev/docs/theming): Presets, and themes of your own. - [Motion tokens](https://www.youj.dev/docs/motion/tokens): The values `get_tokens` and `motion_guidance` return. - [Illustration builder](https://www.youj.dev/illustrations/build): Where `draw_illustration`'s links open. --- # Theming > Choose a preset or set your own colors, type and motion for light and dark modes. Source: https://www.youj.dev/docs/theming Choose your colors, corners, spacing, typefaces and motion. You can also set line weight, shadows, surface shading, title and label styles, and chart colors. Youj uses these choices to calculate the tokens for light and dark modes. Pick your own preset and style: Youj has no brand look you're meant to keep. Without a theme, components draw the Youj palette in the **Clean style** (Inter and JetBrains Mono, rounded rectangles, sentence-case labels), in dark, so a bare page looks right; `npx @youj/cli init` and Create ask you to choose. This site wears **Graphite in light and Basalt in dark**, both the Youj palette in Clean: that pairing is only the docs' own choice. Every preset is tuned for both schemes: its light colors are chosen for light surfaces, not darkened copies of its dark ones. A theme sets colors, type and shared sizing. A [look](https://www.youj.dev/docs/looks) sets how components are built. Classic, the default, gives you bordered panels, rounded-rectangle buttons and outlined fields. Bezel gives you sectioned cards, pill buttons and filled fields. Pair either look with any theme. > **Try a preset:** This site wears Basalt in dark mode and, in light, Basalt's character in Graphite's blue. Its previews start in a stock preset, Graphite in light and Basalt in dark (each toolbar's Palette menu marks it Default). To see another, pick a **Palette** and a **Style** where something is rendered: the Appearance menu in a component's or a block's preview toolbar, or the [builder](#build-your-own) below. Each changes only its previews. To make a theme of your own and take it into your app, use [Create](https://www.youj.dev/create). ## Roles A theme never sets a color by name. It sets a few inputs (the neutral, the accent and status inks, the character, the tone) and the stylesheet derives the **roles** from them, in light and dark. Components paint with roles only: `bg-surface`, `text-fg-muted`, `border-line-control`. So any theme reaches every component, and `checkTheme` can prove every pair a role implies before your app draws it. Use the role that names what the thing *is*, not the color you want: a menu is `overlay`, a tab track is `well` and its selected tab `key`, a sidebar is `chrome`. Every role below is derived again inside each theme scope and tone scope, so the same class is right everywhere. Each role is a Tailwind color: `bg-surface`, `text-fg-muted`, `border-line-control`, `ring-focus`. **Surfaces** | Role | Class | For | | --- | --- | --- | | `canvas` | `bg-canvas` | The page: anything that sits on nothing. | | `surface` | `bg-surface` | A card or panel resting on the page. | | `overlay` | `bg-overlay` | What floats above content: menus, selects, popovers, dialogs, sheets, toasts. | | `well` | `bg-well` | Sunken: tracks, rails, unchecked boxes, skeletons, code. | | `field` | `bg-field` | A text field’s fill. | | `key` | `bg-key` | The lifted part inside a well: the selected tab, a switch thumb, a kbd cap. | | `control` | `bg-control` | A neutral control’s fill: secondary and outline buttons. | | `chrome` | `bg-chrome` | A bar attached to the frame: sidebar, app header, tab bar. Drawn in the chrome tone. | | `chrome-float` | `bg-chrome-float` | Floating chrome: toolbars, islands, capsules, menubars. Drawn in the float tone. | | `inverse` | `bg-inverse` | The page’s opposite: tooltips and ink callouts. Drawn in the inverse tone. | | `scrim` | `bg-scrim` | Dims the page under a modal. | **Lines** | Role | Class | For | | --- | --- | --- | | `line-subtle` | `border-line-subtle` | Inner dividers and table row rules. | | `line` | `border-line` | The default hairline: every border and divider. | | `line-strong` | `border-line-strong` | Emphasized dividers and hover borders. | | `line-control` | `border-line-control` | A control boundary, held to 3:1: fields, checkboxes, radios. | **Text and marks** | Role | Class | For | | --- | --- | --- | | `fg` | `text-fg` | Primary text and icons. | | `fg-muted` | `text-fg-muted` | Secondary text: descriptions, meta, labels. | | `fg-subtle` | `text-fg-subtle` | Tertiary text and placeholders, still 4.5:1. | | `on-accent` | `text-on-accent` | Text and icons on any solid ink: accent, danger, success, warning, info. | | `on-action` | `text-on-action` | Text on the primary action’s fill. | **Actions and states** | Role | Class | For | | --- | --- | --- | | `accent` | `bg-accent` | The one brand ink: on states, links, progress, selection marks. | | `action` | `bg-action` | The primary action’s fill: the accent, or ink when the theme’s actions are ink. | | `danger` | `bg-danger` | Destructive actions, and the danger status. | | `hover` | `bg-hover` | Transient pointer feedback on a neutral item. | | `selected` | `bg-selected` | A persistent neutral selection: the current nav item, a selected row, a pressed toggle. | | `cursor` | `bg-cursor` | The row a menu, list or palette has under the pointer or the keyboard: hover, or ink when the theme's overlays are ink. | | `on-cursor` | `text-on-cursor` | Text on a highlighted row; yj-on-cursor sets it and turns the text roles inside to match. | | `on-cursor-muted` | `text-on-cursor-muted` | Hints and shortcuts on a highlighted row. | | `band` | `bg-band` | A faint band: card footers, table headers, zebra stripes. | | `focus` | `ring-focus` | The focus ring. | **Status** | Role | Class | For | | --- | --- | --- | | `success` | `bg-success` | The success ink, solid. | | `warning` | `bg-warning` | The warning ink, solid. | | `info` | `bg-info` | The info ink, solid: the accent unless the theme sets `info`. | | `accent-text` | `text-accent-text` | Text in the accent’s hue, on a surface or on its own tint. | | `accent-subtle` | `bg-accent-subtle` | A soft accent tint: an accent selection, a “new” pill. | | `accent-line` | `border-accent-line` | A tint border in the accent. | | `success-text` | `text-success-text` | Text in the success hue. | | `success-subtle` | `bg-success-subtle` | A soft success tint. | | `success-line` | `border-success-line` | A tint border in success. | | `warning-text` | `text-warning-text` | Text in the warning hue. | | `warning-subtle` | `bg-warning-subtle` | A soft warning tint. | | `warning-line` | `border-warning-line` | A tint border in warning. | | `danger-text` | `text-danger-text` | Text in the danger hue. | | `danger-subtle` | `bg-danger-subtle` | A soft danger tint. | | `danger-line` | `border-danger-line` | A tint border in danger. | | `info-text` | `text-info-text` | Text in the info hue. | | `info-subtle` | `bg-info-subtle` | A soft info tint. | | `info-line` | `border-info-line` | A tint border in info. | **Data and shadow** | Role | Class | For | | --- | --- | --- | | `chart-1` | `bg-chart-1` | Series 1, the lead series; 3:1 on every surface. | | `chart-2` | `bg-chart-2` | Series 2, warm. | | `chart-3` | `bg-chart-3` | Series 3. | | `chart-4` | `bg-chart-4` | Series 4. | | `chart-5` | `bg-chart-5` | Series 5, cool. | | `chart-quiet` | `bg-chart-quiet` | The mark that isn’t in focus. | | `shadow` | `(shadow-*)` | The ink of a shadow: the neutral hue in light, black in dark. | ### Every class, to copy The class names the theme drives, read from the tokens when the site is built. Each role works with any color utility (`bg-`, `text-`, `border-`, `ring-`, `fill-`, `stroke-`); the one listed is its usual use. - **Surfaces:** `bg-canvas` `bg-surface` `bg-overlay` `bg-well` `bg-field` `bg-key` `bg-control` `bg-chrome` `bg-chrome-float` `bg-inverse` `bg-scrim` - **Lines:** `border-line-subtle` `border-line` `border-line-strong` `border-line-control` - **Text and marks:** `text-fg` `text-fg-muted` `text-fg-subtle` `text-on-accent` `text-on-action` - **Actions and states:** `bg-accent` `bg-action` `bg-danger` `bg-hover` `bg-selected` `bg-cursor` `text-on-cursor` `text-on-cursor-muted` `bg-band` `ring-focus` - **Status:** `bg-success` `bg-warning` `bg-info` `text-accent-text` `bg-accent-subtle` `border-accent-line` `text-success-text` `bg-success-subtle` `border-success-line` `text-warning-text` `bg-warning-subtle` `border-warning-line` `text-danger-text` `bg-danger-subtle` `border-danger-line` `text-info-text` `bg-info-subtle` `border-info-line` - **Data and shadow:** `bg-chart-1` `bg-chart-2` `bg-chart-3` `bg-chart-4` `bg-chart-5` `bg-chart-quiet` - **Corners:** `rounded-control-xs` `rounded-control-sm` `rounded-control` `rounded-control-lg` `rounded-button-xs` `rounded-button-sm` `rounded-button` `rounded-button-lg` `rounded-field-xs` `rounded-field-sm` `rounded-field` `rounded-field-lg` `rounded-field-area` `rounded-choice-xs` `rounded-choice-sm` `rounded-choice` `rounded-choice-lg` `rounded-chip` `rounded-surface` `rounded-overlay` `rounded-overlay-lg` `rounded-mark` - **Control heights:** `h-control-xs` `h-control-sm` `h-control` `h-control-lg` - **Interface type:** `text-ui-xs` `text-ui-sm` `text-ui` `text-ui-lg` - **Shadows:** `shadow-xs` `shadow-sm` `shadow-md` `shadow-lg` `shadow-xs-soft` `shadow-sm-soft` ### Each preset's roles The surfaces carry text in all three weights, and each ink is shown solid and as a soft badge (its `-text` on its `-subtle`, ringed by its `-line`). These are live: the page's stylesheet draws them, in the site's mode. ### Tints An opacity on a role (a slash and a percentage after the role's class) is never right in Youj: it isn't checked for contrast, it changes with whatever is underneath, and it reads differently in dark. Every tint the components need is a role of its own: | Role | Formula | Instead of | | --- | --- | --- | | `hover` | `fg` at 6% | text color at a low opacity, `bg-muted`, shadcn's `accent` | | `selected` | `fg` at 9% | text color at 10%, a selection drawn with the accent | | `band` | `fg` at 5% | half of `muted`, text color at 3% | | `line-subtle` | `line` at 55% | the line at half opacity | | `-subtle` | the ink at 10% in light, 14% in dark | the accent or danger at 10 to 15% | | `-line` | the ink at 35% | an ink border at 20 to 40% | | `-text` | the ink mixed 80% with `fg` | the ink itself as small text | The inks are `accent`, `success`, `warning`, `danger` and `info`. `-text` reads at 4.5:1 on its own `-subtle` over the page and a card, which `checkTheme` holds. Selected is also hoverable: write `bg-selected hover:bg-selected`, never `hover:bg-selected` on a selected item. A faint neutral wash below 5% takes no tint at all. Youj's own lint (`lint:tokens`) refuses `/N` on a role in its source; your app can follow the same rule. ### Tone: two-tone themes A **tone scope** is an element with `data-yj-tone`: `header` on the top bar (AppHeader, block headers), `chrome` on the other bars attached to the frame (Sidebar, the bottom tab bar), `float` on chrome that floats (Toolbar, Menubar, Capsule, ActionIsland, Dock, islands), `overlay` on every menu, popover, dialog and toast, and `inverse` on the tooltip. Components write it on their own root, so you never add it. Inside a tone scope every role is derived again in that tone's mode, so a button, a field, tabs or a menu inside a dark sidebar are their dark selves, by derivation. The theme's `tone` chooses those modes: | `tone` | Values | Default | | --- | --- | --- | | `chrome` | `match` follows the page; `dark` or `light` holds the chrome to that mode | `match` | | `bars` | `chrome` draws headers and sidebars in the chrome's mode; `content` keeps them in the page's, so only floating chrome changes; `top` draws only the top bar in the chrome's mode | `chrome` | | `overlays` | `content` draws menus and dialogs in the page's mode; `chrome` in the chrome's; `ink` in the page's, with the row under the pointer or the keyboard filled with ink (`cursor`) and lettered in paper | `content` | `{ chrome: 'dark' }` on a light page gives dark bars and a dark sidebar around light content. `{ chrome: 'dark', bars: 'content' }` keeps the header and sidebar light and darkens only what floats: islands, toolbars, docks. `bars: 'top'` adds a dark header over a light sidebar. In dark mode it changes nothing, and `chrome: 'light'` is its mirror. Tones are absolute: the inverse is always the page's opposite, wherever it is drawn. ```ts title="app/theme.ts" import { defineTheme, graphite } from '@youj/core' export const studio = defineTheme({ ...graphite, id: 'studio', name: 'Studio', tone: { chrome: 'dark' } }) ``` With `overlays: 'chrome'` the menus follow the chrome as well: In CSS, `tone` is six switches, which a complete theme resets so nothing leaks from an outer scope: `--yj-tone-chrome-fixed` and `--yj-tone-chrome-dark` (each `0` or `1`), `--yj-tone-bars-chrome`, `--yj-tone-header-chrome`, `--yj-tone-overlay-chrome` and `--yj-tone-overlay-ink`. A highlighted row paints `bg-cursor` and wears `yj-on-cursor`, which letters it in `on-cursor` and turns the text roles inside to match. `checkTheme` runs its role matrix under every tone (match, chrome dark, chrome light, chrome dark with chrome overlays, a top bar alone with chrome overlays, and content bars with chrome or ink overlays), so a two-tone theme is held to AA like any other. In [Create](https://www.youj.dev/create), **Chrome**, **Bars** and **Overlays** sit in the theme's basics, and the link and `npx @youj/cli create` carry them. ### Coming from shadcn Youj's names are not shadcn's, and Youj's own source never reads shadcn's. An app that already speaks shadcn's vocabulary adds the compat sheet after Youj's stylesheet, in the same Tailwind root: ```css title="app/globals.css" @import 'tailwindcss'; @import '@youj/react/styles.css'; @import '@youj/react/shadcn.css'; ``` It maps each shadcn name onto the role it means, per theme scope and tone: `background` to `canvas`, `card` to `surface`, `popover` to `overlay`, `primary` to `accent`, `muted` to `band`, `border`, `input` and `ring` to `line`, `line-control` and `focus`, `sidebar` to `chrome`. One name can't map: shadcn's `accent` is a hover surface, and in Youj `accent` is the brand ink. `npx @youj/cli add` rewrites shadcn's `accent` to `hover` (and `accent-foreground` to `fg`) in code it copies in; in your own code, make the same change. [Coming from shadcn/ui](https://www.youj.dev/docs/guides/shadcn) covers the move step by step. ## The contract These are the settings you pass to `defineTheme`, and the CSS variables they produce. `defineTheme` freezes your settings; use `checkTheme` to validate them. Basalt is the baseline: a theme with an `id` takes Basalt's value for every field it leaves out, and so does a page with no theme, so `defineTheme({ id: 'mine' })` is Basalt, and `defineTheme({ id: 'mine', accent })` and `defineTheme({ accent })` agree on everything but colour. That includes the neutral ramp and the colours: an accent or status colour you leave out is Basalt's tuned pair (with the light ink held to AA on your own neutrals), and a chart palette you leave out is Basalt's while you set no colour of your own, then one drawn from your accent. Those are the Youj palette's: Graphite's ultramarine in light, Basalt's amber in dark; set the colours you want. | Field | CSS | Values | Default | | --- | --- | --- | --- | | Neutral hue | `--yj-neutral-hue` | an OKLCH hue in degrees | `285`, Basalt's cool violet-gray | | Neutral chroma | `--yj-neutral-chroma` | `0` (pure gray) to `0.04` | `0.008` | | Neutral wash | `--yj-neutral-wash` | `0` to `1`: how much tint reaches the page and surfaces | `0` | | Neutral lift | `--yj-neutral-lift` | `0` to `1`: in dark mode, how far cards and the surfaces above them stand from the page | `1` | | Neutral page | `--yj-neutral-page` | `gray` `white`: in light mode, white cards on a soft gray page, or a white page with cards and code a gray step down | `gray` | | Accent | `--yj-accent`, or `--yj-accent-light` and `--yj-accent-dark` | any CSS color, or [one per mode](#colors-per-mode) | Basalt's amber | | Success, warning, danger | `--yj-success`, `--yj-warning`, `--yj-danger`, each with `-light` and `-dark` | any CSS color, or one per mode, optional | Basalt's tuned pairs | | Info | `--yj-info`, with `-light` and `-dark` | any CSS color, or one per mode, optional: informational alerts, toasts and badges | the accent | | Chart | `--yj-chart-1` to `--yj-chart-5`, each with `-light` and `-dark` | five CSS colors, or five per mode, optional: the [chart palette](#chart-palette) | Basalt's, or drawn from your accent once you set one | | Radius | `--yj-radius` | `none` `sm` `md` `lg` `xl` `full` | `xl`, 10px on a 32px control | | Control size (`controlSize`) | `--yj-density` | `compact` `default` `comfortable` | `default`, 32px controls | | Fonts | `--yj-font-sans`, `--yj-font-mono`, `--yj-font-display` | font-family lists; `display` sets titles | Inter, JetBrains Mono; titles in Inter | | Motion personality (`motionPersonality`) | `data-yj-motion` | `precise` `calm` `lively` | `precise` | | Stroke | `--yj-stroke-width`, `--yj-stroke-ink`, `--yj-icon-weight` | a width in px, `0` to `1` toward the ink, an icon multiplier | a `1px` neutral hairline, icons as drawn | | Shadow | `--yj-shadow-soft`, `--yj-shadow-hard` | `soft` `hard` `none` | `soft` | | Actions | `--yj-action-ink` | `accent` `ink` | `accent` | | Controls | `--yj-pill` | `pill` `rounded`: the [shape](#shapes) of every control | `rounded` | | Fields | `--yj-field-soft` | `outlined` `soft`: how every [field](#shapes) is drawn | `outlined` | | Surfaces | `--yj-surface-gradient` | `0` to `1`: cards shade toward their foot (lighter in dark), ink buttons carry a sheen | `0`, flat | | Headings | `--yj-heading-weight`, `--yj-heading-tracking` | a weight, a tracking in em | `600`, `-0.025em` | | Tone | `--yj-tone-chrome-fixed`, `--yj-tone-chrome-dark`, `--yj-tone-bars-chrome`, `--yj-tone-header-chrome`, `--yj-tone-overlay-chrome`, `--yj-tone-overlay-ink` | `chrome`: `match` `dark` `light`; `bars`: `chrome` `content` `top`; `overlays`: `content` `chrome` `ink` ([two-tone](#tone-two-tone-themes)) | `match`, `chrome`, `content` | | Labels | `--yj-label-case`, `--yj-label-font`, `--yj-label-tracking` | capitals, the mono face, a tracking | sentence case in the text face (a theme that sets capitals gets a tracking fitted to its face) | The motion personality uses the `data-yj-motion` attribute to select a timing curve. It is not the provider's `motion` prop, which only switches animation on or off, and the control size is not the provider's `density`, which is layout room (see [Density and scaling](#density-and-scaling)). The appearance settings are covered under [Character](#character). ## Colors per mode A color you give once is derived for each scheme: darkened until text in it reads on light surfaces, lifted until it reads on dark ones. That keeps any color legible, but a color that's right in one scheme isn't always right in the other. A pale amber that glows on near-black turns brown once it's darkened enough to read on white. A light green in dark mode wants a deeper, cleaner green in light mode, not a darker copy. So each color can also be given per mode, as `{ light, dark }`. Each side is used exactly as given: ```ts title="app/theme.ts" import { defineTheme } from '@youj/core' export const brand = defineTheme({ id: 'brand', // One color: both schemes are derived from it. success: '#10b981', // Tuned per mode: a signal orange for white, an amber that glows on black. accent: { light: 'oklch(0.528 0.145 47)', dark: 'oklch(0.8 0.165 73)' }, // Five series for each scheme. chart: { light: ['oklch(0.595 0.19 140)', 'oklch(0.6 0.23 358)', 'oklch(0.54 0.25 293)', 'oklch(0.625 0.155 55)', 'oklch(0.6 0.11 222)'], dark: ['oklch(0.9 0.2 129)', 'oklch(0.67 0.21 354)', 'oklch(0.61 0.22 293)', 'oklch(0.8 0.165 73)', 'oklch(0.8 0.13 210)'], }, }) ``` In CSS, a pair is two variables, used as given. Set both, and no single color beside them: where both forms are set, the single color wins, which is what lets `--yj-accent` alone re-brand a page over the default pairs. ```css :root { --yj-accent-light: oklch(0.528 0.145 47); --yj-accent-dark: oklch(0.8 0.165 73); } ``` Nothing moves a color you give per mode, so `checkTheme` holds each side to the same pairs a derived color meets: 4.5:1 as text on every surface in its scheme, and 3:1 for chart series. A side that misses is an error, and `allowLowContrast` accepts it, as for any other pair. Every palette gives its colors per mode. Youj's are the tuned pairs a theme gets when it leaves them unset. Code that reads a theme's colors can tell the two forms apart with `isPerMode(theme.accent)`. To get the color a theme actually draws in each mode, whichever form it uses, call `deriveTheme(theme)`. ## Palette and style A theme is two layers that pair freely: - A **palette** is color only: the neutral (`hue`, `chroma`, `wash`, `lift`, `page`), the `accent`, the `success`, `warning`, `danger` and `info` inks, and the `chart`. A palette never tints the whites and grays: every shipped palette has a `wash` of 0, so pages, cards and fields stay neutral and the color goes to buttons, checks, focus, selection, charts and the backdrop. - A **style** is form only: `radius`, `controlSize`, `fonts`, `stroke`, `shadow`, `actions`, `controls`, `surfaces`, `headings`, `labels`, `motionPersonality`, and the [look](https://www.youj.dev/docs/looks) it's built in. Pick a style and the look follows it. Any palette goes with any style. Pick them on the provider or a scope, by the exported objects: ```tsx title="app/providers.tsx" import { YoujProvider, meadowPalette, brutalStyle } from '@youj/core' {children} ``` Either one alone swaps that half of the theme around it and keeps the other, so `` sets one section in Classic in the app's own colors. A `theme` on the same provider applies on top, and an explicit `look` wins over the style's. In code, `composeTheme(palette, style)` makes the complete theme, and `definePalette` and `defineStyle` declare your own: ```ts title="app/theme.ts" import { composeTheme, definePalette, cleanStyle } from '@youj/core' export const harbor = definePalette({ id: 'harbor', name: 'Harbor', neutral: { hue: 230, chroma: 0.004, wash: 0, lift: 0.5 }, accent: { light: 'oklch(0.5 0.12 240)', dark: 'oklch(0.78 0.1 235)' }, }) export const theme = composeTheme(harbor, cleanStyle) ``` | Style | Look | Radius | Type | Character | | --- | --- | --- | --- | --- | | Clean (default) | Classic | `xl` | Inter, JetBrains Mono | hairline, soft, accent actions, rounded controls, semibold tight titles | | Bezel | Bezel | `lg` | Geist, Geist Mono | hairline, soft, accent pills, a surface gradient, medium titles, tracked mono capitals | | Classic | Classic | `sm` | IBM Plex Sans, Plex Mono | hairline, soft, accent actions, a light surface gradient | | Brutal | Classic | `none` | Inter, JetBrains Mono | 2px ink, hard shadows, heavy titles, mono capitals | | Terminal | Bezel | `none`, compact | JetBrains Mono throughout | hairline, flat, accent pills | **Youj** is the default palette: near-black panels with one amber signal and neon charts in dark, cool grays with one electric ultramarine in light. The others are drawn from Sanzo Wada's *A Dictionary of Color Combinations*: Meadow, Sandstone, Cinnabar, Umber, Seaglass and Heather. Each is exported as `Palette` (`youjPalette`, `meadowPalette`) and listed in `palettes`; the styles as `Style` and in `styles`. Each Wada palette matches a [backdrop](https://www.youj.dev/docs/backdrops) of the same name, and picking it in a preview brings that backdrop; Youj's is the docs' aurora. In CSS, each layer is an attribute next to `data-yj-theme` (which marks where the roles re-derive), from `lib/youj/theme/palettes.css` and `lib/youj/theme/styles.css` (in `@youj/core/styles.css`, or the `youj-palettes` and `youj-styles` registry items): ```html ``` ## Presets A preset is a named pair of a palette and a style. `DEFAULT_PRESET` (Basalt) is a starting suggestion, not a default you're meant to keep: what `init --yes` and Create start from. Its values, the Youj palette in Clean, are what a theme of your own takes for the fields it leaves unset (see [the contract](#the-contract)), and what a page with no theme draws. Use any preset as it is or as a starting point. Each is available through the package stylesheet and as a JavaScript export. | Preset | Palette | Style | | --- | --- | --- | | Graphite | Youj | Clean | | Terminal | Youj | Terminal | | Brutal | Youj | Brutal | | Basalt | Youj | Clean | | Meadow | Meadow: one grass green, sky and gold in the data | Clean | | Sandstone | Sandstone: one raw sienna, a night blue for information | Clean | | Cinnabar | Cinnabar: one English red, ivory in the data; made dark first | Clean | | Umber | Umber: one brass, a slate blue for information | Clean | | Seaglass | Seaglass: one deep sea blue, cream and aqua in the data | Clean | | Heather | Heather: one heather plum, old rose and sage in the data | Clean | Each shipped preset is exported by its id: `graphite`, `terminal`, `brutal`, `basalt`, `meadow`, `sandstone`, `cinnabar`, `umber`, `seaglass` and `heather`. Every style moves with the `precise` personality; `calm` and `lively` are there for your own. The site runs the lively personality; your app uses its style's. ```css title="@youj/core/lib/youj/theme/palettes.css" [data-yj-palette='meadow'] { --yj-neutral-hue: 100; --yj-neutral-chroma: 0.004; --yj-neutral-wash: 0; --yj-accent-light: oklch(0.5 0.11 148); --yj-accent-dark: oklch(0.79 0.13 150); --yj-success-light: oklch(0.5 0.085 185); --yj-success-dark: oklch(0.79 0.1 185); --yj-warning-light: oklch(0.52 0.11 68); --yj-warning-dark: oklch(0.8 0.13 75); --yj-danger-light: oklch(0.53 0.17 25); --yj-danger-dark: oklch(0.72 0.15 25); --yj-chart-1-light: oklch(0.46 0.099 148); --yj-chart-1-dark: oklch(0.81 0.13 150); /* … chart-2 to chart-5 */ } ``` ```ts title="app/theme.ts" import { composeTheme, definePalette, terminalStyle } from '@youj/core' export const phosphor = definePalette({ id: 'phosphor', name: 'Phosphor', neutral: { hue: 150, chroma: 0.004, wash: 0 }, accent: { light: 'oklch(0.49 0.145 146)', dark: 'oklch(0.86 0.23 145)' }, success: { light: 'oklch(0.495 0.08 195)', dark: 'oklch(0.74 0.12 195)' }, warning: { light: 'oklch(0.515 0.125 58)', dark: 'oklch(0.82 0.15 80)' }, danger: { light: 'oklch(0.53 0.19 27)', dark: 'oklch(0.665 0.175 25)' }, }) export const theme = composeTheme(phosphor, terminalStyle) ``` ## Apply a theme ### With CSS Import the component stylesheet, then set the preset, scheme and style on ``. This applies them to the whole document, including overlays. ```css title="app/globals.css" @import '@youj/react/styles.css'; ``` ```html ``` For Terminal in light mode, change the theme and the scheme: ```html ``` You can also set the theme variables yourself, after importing the stylesheet. On `:root` it re-themes the whole app. On an element with a `data-yj-theme` attribute it themes that element's subtree, because the attribute makes it a scope where the tokens are derived again. ```css title="app/globals.css" :root { --yj-neutral-hue: 60; --yj-neutral-chroma: 0.01; --yj-accent: #ff5a1f; --yj-radius: 0.25rem; } ``` A single color like `--yj-accent` wins over the per-mode pairs the page wears by default (Basalt's), so the one line re-brands both schemes. Over a preset applied with `data-yj-theme` it wins too, as long as your rule comes after the stylesheet. Status and chart colors work the same way: `--yj-success`, `--yj-chart-1`. ### With JavaScript Spread a preset to keep its settings, then change the fields you need. Spread a nested group too if you want to keep its other values. This theme keeps Basalt’s fonts, charts and surface shading: ```tsx title="app/theme.ts" import { basalt, defineTheme } from '@youj/core' export const brand = defineTheme({ ...basalt, id: 'brand', name: 'Brand', accent: '#ff5a1f', neutral: { ...basalt.neutral, hue: 60, chroma: 0.01 }, radius: 'sm', controlSize: 'compact', motionPersonality: 'calm', }) ``` ```tsx title="app/providers.tsx" import { YoujProvider } from '@youj/core' import { brand } from './theme' export function Providers({ children }: { children: React.ReactNode }) { return {children} } ``` `YoujProvider` applies the theme to its subtree and carries it onto everything the subtree portals, such as menus, dialogs and toasts. `useYouj()` follows the theme's motion personality (`motionPersonality`), and `useTheme()` reads the resolved theme. A bare provider uses the defaults: Classic and Basalt. It leaves the scheme to `data-theme` on ``, dark when it's unset; to set it from the provider, see [Color mode](#color-mode). ### Without a flash A provider renders its theme into the server HTML, so nothing flashes on load. To theme `` itself (the page background, the scrollbars, and anything portaled to `` from outside a provider), spread `themeProps` on it in the root layout: ```tsx title="app/layout.tsx" import { themeProps } from '@youj/core' import { brand } from './theme' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### A theme picker for the whole app A provider with a theme in state themes its subtree, but `` keeps the theme the server rendered, so the page background and scrollbars stay in the old one. To switch the whole app, put the same attributes on `` when the choice changes. `themeProps` is plain data, so it works in a client file: ```tsx title="app/providers.tsx" 'use client' import { useEffect, useState, type ReactNode } from 'react' import { basalt, themeProps, YoujProvider, type ThemeInput } from '@youj/core' export function Providers({ children, themes }: { children: ReactNode; themes: ThemeInput[] }) { const [theme, setTheme] = useState(basalt) useEffect(() => { const { style, ...attributes } = themeProps(theme) for (const [name, value] of Object.entries(attributes)) document.documentElement.setAttribute(name, String(value)) for (const [name, value] of Object.entries(style)) document.documentElement.style.setProperty(name, String(value)) }, [theme]) return ( {children} ) } ``` The same goes for the other switches on the provider: `look="classic"` and `motion={false}` are props, so keep them in state the same way. ### Part of a page `ThemeScope` themes a subtree, and scopes nest: ```tsx import { brutal, ThemeScope } from '@youj/core'
``` > **Complete themes and overrides:** A theme with an `id`, like every preset, is complete: anything it leaves out takes the default (Basalt's), never the surrounding theme's value. So a preset nested inside another looks exactly as it does on its own. A theme without an `id` is an override: it changes only what it sets. > **Overlays and CSS-only scopes:** Overlays render at the end of ``. A theme on `` reaches them, and so does one set with `YoujProvider` or `ThemeScope`, which carry it onto their portals. A theme set only in CSS on an inner element doesn't reach them, so wrap a scope that opens menus or popovers in `ThemeScope`. ### Copy in a theme An app that copies the core (`npx @youj/cli init --mode registry`) gets the theme contract, every preset as a JavaScript export and the stylesheets of Basalt and Graphite. The stylesheets of the other presets are items of their own, since only an app that themes with `` attributes (Vite, Astro) imports one: ```bash npx @youj/cli add theme terminal # also brutal, meadow, sandstone, cinnabar, umber, seaglass, heather ``` This copies `lib/youj/theme/themes/terminal.css` and, in a Vite or Astro app, imports it after `youj.css`. `init` adds the stylesheet of the preset you pick on its own. A theme set through ``, `ThemeScope` or `themeProps` needs no stylesheet. ## Color mode The palette has a dark and a light scheme, and `YoujProvider`'s `mode` chooses between them: | `mode` | Scheme | | --- | --- | | `'dark'` | Dark. The default. | | `'light'` | Light. | | `'system'` | Follows the operating system's `prefers-color-scheme`, live. | A page that sets no mode is dark, as the palette always was. The mode is written as `data-theme` (and `color-scheme`) on `` by the top-level provider, put back when it unmounts, and on the subtree by a nested provider or a `ThemeScope`, which also carries it onto what they portal: ```tsx title="app/providers.tsx" import { YoujProvider } from '@youj/core' export function Providers({ children }: { children: React.ReactNode }) { return {children} } ``` ```tsx ``` On the top-level provider, `mode` is the default until the visitor picks one: a choice from an uncontrolled [ThemeToggle](https://www.youj.dev/docs/components/theme-toggle) or `setThemeMode()` (stored in `localStorage` under `"theme"`) wins over it, and `ModeScript` applies it too, so a toggle works under `mode` with nothing more to wire. Leave `mode` out and Youj doesn't touch `data-theme`: your own tooling (`next-themes`) can own it, as in [Installation](https://www.youj.dev/docs/installation#theme). Don't set both. To switch the mode from your own code, avoid a flash on load or read the mode in a component, see [Light, dark and system](https://www.youj.dev/docs/guides/light-dark-and-system#from-your-own-control). ## Density and scaling Standard size is the default: roomy panels at 100%, which suits most apps and every marketing, auth and content page. A dense pro tool (an admin console, an editor, a trading screen) can opt into less room. Two provider props size the UI, apart from the theme: - **Layout density** (`density`) sets how much room panels give their content: card and dialog padding, sheet and popover padding, table and list row heights, the gap between fields. `roomy` is the default. `compact` is for dense pro tools, with 16px panel sides and 36px rows. - **Scaling** (`scaling`) sizes the whole UI by one factor, from `75%` to `120%`: type, spacing, control heights, radii and icons. Hairlines, focus rings and breakpoints hold, and no control goes below 24px. Below 100% applies to fine pointers only, so phones and tablets keep their touch targets, and so does a subtree forced to [touch](https://www.youj.dev/docs/guides/touch-mode). The theme's `controlSize` is a third, separate setting: the control height (`--yj-density`, see [the contract](#the-contract)). It moves buttons and inputs 4px a step and leaves panels alone. | Prop | Type | Default | Writes | | --- | --- | --- | --- | | `density` | `'compact' \| 'roomy'` | inherited, else `'roomy'` | `data-yj-density` | | `scaling` | `'75%'` `'80%'` `'85%'` `'90%'` `'95%'` `'100%'` `'110%'` `'120%'` | inherited, else `'100%'` | `data-yj-scaling`, the number without `%` | Both are props of `YoujProvider` and `ThemeScope`, and both reach portaled overlays. Neither is set by default, so an app that doesn't ask keeps every size as it was. `npx @youj/cli init` and `create` leave the provider at its standard size, with no size props. For a dense pro tool, opt in on the root provider: ```tsx title="app/layout.tsx" {children} ``` A scope sets a region apart. In a compact tool, give a marketing or settings page the standard size back: ```tsx title="app/pricing/page.tsx" import { ThemeScope } from '@youj/core' export default function Page() { return ( ) } ``` Scopes nest both ways: a compact region inside a roomy page, or a roomy one inside a compact app. Without JavaScript, put the attributes on any element: `
`. Blocks set no density of their own: each takes the page's, so a block in a compact tool is compact and a block at the standard size is roomy. To set one block apart, wrap it in a `ThemeScope`, which also reaches what it portals. Layout density moves panels, not controls: a button looks the same at both. Card, Dialog, Sheet, Drawer, Popover, Tabs, Field and FieldGroup, Alert, Empty, Table, Data Table, Data Grid and the sidebar's rows follow it, and so does every block built from them. `DENSITIES` and `SCALINGS` list the values. In your own components, `useYoujLayout()` returns the `density` and `scaling` in effect, for a size that lives in JavaScript, such as a virtualized row height. It reads the nearest provider or scope, so it doesn't see attributes written by hand. To see a size before you set it, use the **Size** control in any preview's toolbar on this site (in the Settings sheet on a phone). It sets the density and the scale together, re-renders the preview at once, and holds for every preview until you press **Reset**. Every preview starts at the standard size, roomy at 100%; pick Compact to see a block as a dense tool would have it. In [Create](https://www.youj.dev/create), the size you pick is the one the export writes on your root provider (none, at the standard size). ## Text and type The provider sets colors, fonts and the scheme, but plain text inside it keeps your page's own size: a `

` or a `

` doesn't pick up the components' text size. Next to a Button at 13px, your 16px body copy looks large. Pick the size for your text from the tokens: | Class | Size at 100% | For | | --- | --- | --- | | `text-ui-xs` | 11px | Captions and counts. | | `text-ui-sm` | 12px | Helper text and metadata. | | `text-ui` | 13px | The size components use. | | `text-ui-lg` | 14px | Emphasis within a panel, card titles. | They scale with the provider's [`scaling`](#density-and-scaling) and never go under 11px. For text colors use `text-fg`, `text-fg-muted` and `text-fg-subtle`. For long-form text, the [Typography](https://www.youj.dev/docs/components/typography) components (`TypographyH1` to `TypographyH4`, `TypographyP`, `TypographyLead`, `TypographyMuted`) set size, line height and spacing together, so a marketing page or a changelog reads right without any classes. ## Character Use these settings to change borders, shadows, button fills, surface shading and typography. In a named theme, omitted fields use Basalt’s values. Spread a preset first if you want to keep its appearance. | Field | What it changes | Values | | --- | --- | --- | | `stroke.width` | Every border, divider and separator, and the focus ring (1px heavier, 2px at least) | px, `0.5` to `4` | | `stroke.ink` | How far lines and field borders lean from the neutral line color toward the text color | `0` (hairline) to `1` (ink) | | `stroke.icons` | Every icon's stroke, as a multiple of its own | `0.5` to `2` | | `shadow` | Elevation. `hard` draws one solid block in ink, offset down and to the right by 1.5 to 4 strokes, and buttons that cast one press into it | `soft` `hard` `none` | | `actions` | What fills a primary action (the default Button). `ink` fills it with the text color, lettered in the page color, and leaves the accent to checkboxes, switches, sliders, selection and data | `accent` `ink` | | `controls` | The [shape](#shapes) of every control, in both looks: buttons and toggles, fields, tab and toggle tracks, badges and chips. `rounded` gives them the control radius instead of pills, in proportion to each one's height (8px on a 32px control in `lg`, 9px on 36px), with keys and rows at the concentric radius inside; switches, radios and avatars stay round | `pill` `rounded` | | `fields` | How every field is drawn: inputs, select triggers, textareas and the pickers. `outlined` is the field fill on a 3:1 edge; `soft` is a filled band with no edge (focus and an invalid value still draw one), which leaves the field's boundary under WCAG 1.4.11's 3:1, so use it where fields are identifiable otherwise | `outlined` `soft` | | `surfaces.gradient` | Cards shade toward the next neutral step at their foot, lighter in dark and a touch deeper in light, and an ink-filled action gets a sheen: a lit top and a deeper foot. The foot never passes that step, so every text pair keeps its contrast; accent and danger fills stay flat for the same reason | `0` (flat) to `1` | | `headings` | Titles set in `font-semibold`: cards, dialogs, sheets, popovers and page headings | `weight`, `tracking` in em | | `labels` | Badges and chips, column heads, the label over a field, menu and sidebar group headings, tabs and keys ([the full list](#what-labels-reaches)). A label beside a checkbox stays a sentence | `case: 'upper'`, `font: 'mono'`, `tracking` | | `fonts.display` | The titles' face | a font-family list | ```ts title="app/theme.ts" import { defineTheme } from '@youj/core' export const brutal = defineTheme({ id: 'brutal', name: 'Brutal', neutral: { hue: 95, chroma: 0.02, wash: 0.5 }, accent: 'oklch(0.54 0.265 295)', radius: 'none', stroke: { width: 2, ink: 1, icons: 1.3 }, shadow: 'hard', headings: { weight: 800, tracking: -0.035 }, labels: { case: 'upper', font: 'mono' }, }) ``` Basalt is the quiet end of the same fields: hairlines and soft shadows kept, rounded rectangles, flat cards that stand clear of the page in dark, and data in neon. Its colors are [tuned per mode](#colors-per-mode): in light mode it wears the Youj palette's ultramarine and deep-ink series. It's set in Inter, with labels in sentence case. ```ts title="app/theme.ts" import { cleanStyle, composeTheme, youjPalette } from '@youj/core' export const basalt = composeTheme(youjPalette, cleanStyle, { id: 'basalt', name: 'Basalt' }) ``` Components use these settings through shared tokens, in both looks. Bezel follows `actions` (its primary pill takes the theme's action fill), `labels` (its eyebrows, column heads and field labels take the theme's label voice), `controls` and `fields` (see [Shapes](#shapes)). What Bezel decides on its own is the anatomy: sectioned cards, filled fields, keyed tracks. Your `className` still wins over it; see [overriding a recipe](https://www.youj.dev/docs/looks#overriding-a-recipe). ### What `labels` reaches `labels` is the voice of the kit's short labels. `case: 'upper'` (and `font`, `tracking`) reach: - **Every Badge and the chips built on one:** MultiSelect's and TagInput's values, PropertyTag, a ChoiceCard's badge and Kanban's column counts. - **Keys:** every `Kbd` (a menu item's `DropdownMenuShortcut` is plain text and keeps its case). - **Column heads:** `TableHead`, and the data table's and data grid's header rows with their sort buttons. - **A field's label above its control** (a vertical `Field`). A label beside a checkbox, radio or switch stays a sentence. - **Group headings:** `SelectLabel`, `DropdownMenuLabel`, the menubar's labels, `SidebarGroupLabel` and the Command palette's group headings. - **Tabs:** every `TabsTrigger`. - **The data-display components' own labels:** StatCard, the meters, rings and dials, the rails and rulers, CalendarHeatmap and the others whose captions read the label voice. The Bezel style adds its card eyebrows. To keep one of them in sentence case, give it `normal-case` (a utility beats the label rule; add `font-sans tracking-normal` to drop the mono face and tracking too). For a whole region, such as a table of user-written tags, reset the three variables on its wrapper and everything inside follows: ```tsx Design review
{/* labels in here keep the case they were written in */}
``` ### Shapes A theme's shape is one decision. Each control family reads one corner, derived from `radius` and `controls`, and nothing else sets it: not the look, not another component, not a block. So within a theme every button, field, tab track and badge shares one shape (all rounded in Basalt and Graphite, all pills in a theme in the Bezel style, all square in Brutal and Terminal), and changing the theme changes all of them. | Family | Token (Tailwind) | Used by | | --- | --- | --- | | Button | `rounded-button`, `-xs` `-sm` `-lg` | Button, AsyncButton, Toggle, ToggleGroup (joined), InputGroupButton | | Field | `rounded-field`, `-xs` `-sm` `-lg`; `rounded-field-area` | Input, Select and NativeSelect triggers, Combobox, InputGroup and the fields built on it (NumberInput, PasswordInput, the date and time pickers); a field that grows past one line (Textarea, TagInput, MultiSelect) caps at `rounded-field-area`, 12px at most | | Choice | `rounded-choice`, `-xs` `-sm` `-lg`; `rounded-choice-key` | the pill tracks of Tabs, SegmentedControl and ToggleGroup; the key inside is the track's corner less its inset, so the two stay concentric | | Chip | `rounded-chip` | Badge, FilterChips | | Surface | `rounded-surface` | Card (capped by the layout density) | | Overlay | `rounded-overlay`, `rounded-overlay-lg` | popovers, menus, select lists and hover cards; dialogs and sheets | Each size keeps one proportion: a pill at every height, or the control radius scaled to the control's height. Fields follow `fields` the same way: every field reads `--ui--field-line` and `--ui--field-fill`, so a field is never made soft or outlined where it's used. To give one family a shape of its own, set its switch on the theme's scope: `--yj-shape-button`, `--yj-shape-field`, `--yj-shape-choice` or `--yj-shape-chip`, `1` for pills and `0` for rounded. Unset, each follows `controls`. ```css title="app/globals.css" /* Basalt's rounded controls, with pill fields. */ :root { --yj-shape-field: 1; } ``` In your own code, use the control's props (`size`, `variant="ghost"` on a SelectTrigger, Badge's `size`) or these tokens rather than a fixed `rounded-full` or `h-10` on a kit control. > **Load your theme’s fonts:** A theme names font stacks; your app loads the font files. `npx @youj/cli init` loads the preset's, and Create’s export includes its theme's. Basalt (the Clean style) and the Brutal style use Inter and JetBrains Mono, which is also what a theme that leaves its fonts out gets, and Terminal JetBrains Mono, through stacks that ask for `'Inter Variable'` and `'JetBrains Mono Variable'`: the names `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono` register. Install those and import them once in your root layout or entry (`import '@fontsource-variable/inter'`); `next/font` and Google register `'Inter'`, which those stacks don't match. The Bezel style uses Geist and Geist Mono (`next/font`'s `Geist` and `Geist_Mono`, or the Google Fonts stylesheet), and the Classic style IBM Plex. If a font is unavailable, the browser uses the next face in its stack. ## Chart palette Charts read `var(--color-chart-1)` to `var(--color-chart-5)`. Every palette tunes five for each scheme, in its own character: | Palette | Series 1 to 5 | | --- | --- | | Youj | light: blue, rose, violet, amber, teal; dark: lime, hot pink, violet, amber, cyan | | Meadow | grass, gold, pine, sky, dusk blue | | Sandstone | sienna, rust, cinnamon, ecru, night blue | | Cinnabar | English red, ember, ivory gold, ash, slate | | Umber | brass, umber, olive, clay, slate blue | | Seaglass | sea blue, cream, navy, ecru, aqua | | Heather | plum, old rose, sage, lilac, heather violet | They share their roles, and blocks rely on them: series 1 leads, series 2 is warm and series 5 cool (the color of cold, water and rest). Neighbors differ in lightness as well as hue, so they stay apart for readers with red-green color blindness too. A theme that leaves `chart` unset (in Create, the palette "From the accent") draws one from its accent: the accent itself, then four hues around the color wheel from it, each at a lightness of its own, all at 3:1 on every surface and inside sRGB. A theme that sets `chart` gets five series colors of its own instead, in order: ```ts import { defineTheme } from '@youj/core' export const ops = defineTheme({ id: 'ops', chart: ['#b4f750', '#f0509e', '#8b5cf6', '#fdaa1c', '#2fd4ec'], }) ``` Youj adjusts these colors for each scheme so chart marks reach 3:1 contrast on every surface they're drawn on. Text colors need 4.5:1. Use a foreground text token for chart labels rather than assuming a series color is readable as text. A palette that glows on black rarely survives being darkened for white: lime turns olive, amber turns brown. Give `chart` as `{ light, dark }`, five colors each, to choose the light series yourself; they're used as given and checked at 3:1. `checkTheme` checks each series in both schemes and warns about colors outside sRGB. Browsers without relative color syntax keep a palette given per mode; for any other, they draw a one-hue ramp of the accent. ## Motion personality | Personality | Feel | `base` | `slow` | `enter` curve | `settle` | | --- | --- | --- | --- | --- | --- | | `precise` | Fast and confident; lands without overshoot | 220ms | 340ms | `cubic-bezier(0.16, 1, 0.3, 1)` | bounce 0.3 | | `calm` | About 1.4× longer, soft at both ends | 300ms | 460ms | `cubic-bezier(0.33, 1, 0.68, 1)` | no bounce | | `lively` | About 15% quicker; arrivals overshoot a touch | 190ms | 290ms | `cubic-bezier(0.34, 1.3, 0.64, 1)` | bounce 0.4 | Token names stay the same and only their values change, so components need nothing new. `useYouj()` returns the personality's values for Motion-driven animation, and the CSS presets read the same numbers through `--yj-duration-*` and the `ease-*` curves. Speed and reduced motion still apply on top. ## What's derived - **Neutrals.** Eight steps from `canvas` to `fg`, each a fixed lightness at a share of the neutral chroma. Light mode lays an app out: white cards, menus and fields on a soft gray page, wells a step below it, on hairlines. A white page (`neutral.page`) swaps the page and the cards, for a document such as this site. Wash pushes the tint onto the page and surfaces, and lowers the light page a little to make room for it. Lift, in dark mode, doubles the step from the page to a card, raises overlays and lines with it, and lifts `fg-subtle` so it keeps its contrast; light mode is unchanged. - **Accent and status.** In light mode a color is darkened in linear light until text in it reads at 4.5:1 on the darkest surface; its hue and saturation stay put. In dark mode it's lifted to a lightness floor and its chroma is capped. A color given per mode skips both and is used as given. Text on the accent and the focus ring follow it. - **Chart colors.** A [chart palette](#chart-palette) is derived the same way, held to 3:1, with a lower dark floor and a higher chroma cap. Without one, a palette is drawn from the accent: the accent, then four hues around the wheel from it, each scaled to a luminance of its own that holds 3:1. - **The roles.** Every [role](#roles) from the steps and inks above: the surfaces, lines, text, states, tints and chart, again inside each theme scope and [tone scope](#tone-two-tone-themes). And the elevation shadows, which take the neutral hue. shadcn's names come only with the [compat sheet](#coming-from-shadcn). - **Shape and density.** The radius scale grows from `--yj-radius` with caps, so `full` makes controls pills while cards stay rectangles. Every control family takes its corner from `controls` ([Shapes](#shapes)): the control radius, in proportion, unless the theme sets `controls: 'pill'`. Control heights move 4px per density step, and 8px more on touch screens. Graphite derives its own hand-tuned palette exactly. ## Guardrails `checkTheme` derives a theme in both modes and lists every problem: unreadable or out-of-range inputs, any pair the library draws that misses WCAG AA, and colors that fall outside sRGB. ```ts import { checkTheme, defineTheme } from '@youj/core' const issues = checkTheme(defineTheme({ accent: '#ffd400' })) // Inspect each issue’s level, token and message. ``` Each issue has a `level`. A pair that misses WCAG AA (text below 4.5:1, a control boundary below 3:1) is an `error`, like an input Youj can't use; an out-of-gamut color is a `warning`. `themeErrors(theme)` returns just the errors: the list that blocks a theme. In an app that copies the core, `checkTheme` and `themeErrors` aren't there: the core keeps a no-op in place of the check, since production builds strip it. `npx @youj/cli add theme check` (`youj-theme-check`) replaces it with the real one; import `checkTheme` from `@/lib/youj/theme/check`. Create and the export tools block themes with errors. Providers report errors in development; they still render the theme: | Where | A theme that misses AA | | --- | --- | | `YoujProvider`, `ThemeScope` (development) | `console.error`, once per theme, naming each failing pair | | [Create](https://www.youj.dev/create) | the command and the .zip are disabled until the Theme step passes | | `npx @youj/cli create ''` | refused, with the failing pairs; `--allow-low-contrast` writes it anyway, with a warning | | MCP `create_theme` | refused, with the failing pairs; `allowLowContrast: true` returns it, with a warning | If you've checked that the failing pairs aren't drawn in your app, opt out on the theme itself, so the choice is visible in code and travels with the theme: ```ts import { defineTheme } from '@youj/core' import { brand } from './theme' export const acceptedTheme = defineTheme({ ...brand, allowLowContrast: true }) // Any contrast failures become warnings. Invalid inputs remain errors. ``` The checks cover these pairs in both schemes. Keep `neutral.chroma` within `0` to `0.04`, and wash and lift within `0` to `1`: - `fg`, `fg-muted` and `fg-subtle` read at 4.5:1 on every surface role (`canvas`, `surface`, `overlay`, `well`, `field`, `key`, `control`, `chrome`, `chrome-float`, `inverse`), each in the tone it's drawn in, under every tone a theme can set, so every text color is real text. - Field borders (`--color-line-control`) reach 3:1 on every surface, the minimum WCAG 1.4.11 sets for control boundaries. - Accent and status colors read at 4.5:1 as text, and so does text on the accent. Each ink's `-text` reads at 4.5:1 on its own `-subtle`, `on-accent` on every ink and `on-action` on `action`. Colors given per mode are checked as given, each in its own scheme. - The focus ring reaches 3:1 on every surface, and the fills that must read apart do: `hover` from a card, `selected` from `hover`, `well` from a card, `chrome-float` from the page. - A theme's chart colors read at 3:1 on every surface, the minimum for marks. - The theme’s surface gradient stays within one neutral step of `surface`. Check any custom surface colors separately. Canvas, WebGL and Lottie code that draws with token colors: see [Styling hooks](https://www.youj.dev/docs/guides/styling-hooks#canvas-webgl-and-lottie). ## Build your own Use the builder below to try a preset and adjust its colors, type and appearance. The previews and checks update as you edit. To use the theme in an app, open [Create](https://www.youj.dev/create) and choose the **Theme** step. Choose your frame and style, then open **Export**. Copy its command and run it in your app: ```bash npx @youj/cli create '' ``` The CLI installs the selected frame and writes your theme to `lib/theme.ts`. Keep the link in quotes so your shell preserves its settings. Add `--dry-run` to preview the changes, or `--mode registry` to copy source files into an app that has not been set up yet. Create also offers a ZIP and code you can copy by hand. ## Browser support Youj supports Chrome 123, Safari 17.5 and the current Firefox, or later. The neutrals need only `oklch()`, `color-mix()` and `calc()`, like the rest of Tailwind CSS v4. Deriving a theme's own accent, status and chart colors uses relative color syntax and `pow()`: Chrome 120, Safari 18 and Firefox 128 or later. Safari 17.5 keeps Graphite's accent and status colors, and draw charts without a palette in a one-hue ramp of the accent. Colors given per mode need no derivation, so they hold in every browser: every preset's chart palette among them. --- # Looks > Choose how your components look with Classic or Bezel, paired with any theme. Source: https://www.youj.dev/docs/looks A [theme](https://www.youj.dev/docs/theming) sets colors, type, spacing, corners and motion. A **look** sets the shape and structure of each component: sectioned or padded cards, pill or rectangular buttons, filled or outlined fields, and segmented or underlined tabs. Each [style](https://www.youj.dev/docs/theming#palette-and-style) names the look it's built in (Bezel and Terminal in Bezel; Clean, Classic and Brutal in Classic), and a `look` prop overrides it. Classic and Bezel are names on both axes: the Bezel style is built in the Bezel look, and Clean, Classic and Brutal are built in the Classic look. A style picks its look; `look` overrides it. | | Theme | Look | | --- | --- | --- | | Decides | color, type, radius scale, density, motion, character | the anatomy of each component | | Set with | `theme={basalt}` or a custom theme | `look="classic"` or `look="bezel"` | | Lives in | `@youj/core` (the tokens and the presets) | `@youj/react` (component looks) | | Default | Basalt, in dark | Classic | Classic is the default look: thin-bordered panels, rounded-rectangle controls, outlined fields and underline tabs, with no CSS rules of its own. Bezel builds components as instrument panels. It was designed alongside the Bezel style, but uses the colors, type and corners of your chosen theme. Selecting Bezel brings in its CSS rules, described below. ## The anatomy These shared rules keep Bezel components consistent across a screen. Two of them speak in the theme's voice: labels take its `labels` setting and the primary action its `actions` fill, so the Bezel style's mono capitals are its own, and a theme in another style under Bezel keeps its plain labels. - **A card's head is one line.** The title, then its description inline as a muted "/ subtitle", then a disc at the end for the card's one action. On a narrow card the subtitle drops below the title. Built with: `CardTitle`, `CardDescription`, and a ghost icon `Button` in `CardAction`. Text in the action reads as a mono status line. - **Sections step lighter.** Under the head, each content block and the footer is a section on a full-bleed hairline, each a step lighter than the one above (a touch darker in light), so the panel is lit from below. Built with: One `CardContent` per section, then `CardFooter`. `variant="inset"` puts them in a well at the concentric radius. - **Labels are eyebrows, values sit right in mono.** The short name over a figure, a group or a column is small and set in the theme's label voice (the Bezel style's: tracked mono capitals). Its value sits at the end of the same row in mono. Built with: `Eyebrow` (or `data-slot="eyebrow"`), a field's label over its control, `ProgressLabel` and `ProgressValue`. - **Numbers are mono, units are muted.** Figures are set in mono with tabular digits. A unit, a percent sign and a currency's cents are set small and muted beside them. Built with: `Counter`, `StatCard`, the meters' values. - **Meters are segmented or ruled.** A quantity reads against a scale: cells that fill, a rule with ticks, or a thin recessed rail. Never a fat bar. Built with: `SegmentedMeter`, `LimitMeter`, `Gauge`, `HistogramRange`, `Progress`. - **Fields are filled wells.** A recessed fill with about 12px corners on the field border, which holds 3:1 against the page in both modes; in light mode the field is white, as cards and menus are. Focus brings the accent ring and halo; an invalid field keeps its danger edge. Built with: `Input`, `Textarea`, `Select`, `Combobox`, `NumberInput`, `InputGroup`. - **Actions are pills.** The primary action takes the theme's action fill (the palette's accent in every shipped style; `actions: 'ink'` leaves the accent to state and selection); a secondary one is a raised filled pill; an icon button is a disc. Built with: `Button`: `default`, `secondary`, and the `icon` sizes. - **Tables are dense, with mono capital heads.** Rows on hairlines with no zebra, column heads in small mono capitals, figures in mono at the end of their column. Built with: `Table` and the data tables (a numeric column marks its cells `data-numeric`). - **Status is a soft tinted pill with a dot.** A state is its color at low strength behind its own ink, led by a dot, never a saturated block. Built with: `Badge` in `success`, `warning` or `danger`, with `BadgeDot` first. These rules follow the theme’s [shapes](https://www.youj.dev/docs/theming#shapes), which both looks read: Bezel decides the anatomy, never the corner. A pill is the control radius scaled up, so a square theme such as Terminal or Brutal stays square, and a theme that rounds its controls (`controls: 'rounded'`, as the Clean and Classic styles do) draws every button, field, track and key at its control radius instead, in proportion to each control’s height. And the accent is spent on state, not decoration: the selected key, the focus ring, a live value, the fill that matters. ## Set a look ### The whole app For Classic there's nothing to set: a root `YoujProvider` writes `data-yj-look="classic"` on its subtree without being asked, and carries it onto everything the subtree portals, such as menus, dialogs and toasts, as it does the theme. For Bezel, pass `look="bezel"`: ```tsx title="app/providers.tsx" import { basalt, YoujProvider } from '@youj/core' export function Providers({ children }: { children: React.ReactNode }) { // Classic by default; look="bezel" for the instrument-panel anatomy. return {children} } ``` `useYoujLook()` reads the look in effect. ### On the server, or without JavaScript The recipes key on the `data-yj-look` attribute, so no JavaScript is needed. Without a provider and without the attribute, components draw Classic. Writing `data-yj-look="bezel"` on `` switches the whole document to Bezel, overlays included. `npx @youj/cli init` writes the attribute for you. ```tsx title="app/layout.tsx" import { basalt, themeProps } from '@youj/core' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### Bezel, for all or part of a page `look="bezel"` on the provider, on a `ThemeScope`, or on `` as `data-yj-look="bezel"`: ```tsx import { ThemeScope } from '@youj/core' ``` A Classic scope inside a Bezel one puts its subtree back to Classic. A Bezel scope inside that one isn't supported: its subtree stays Classic. ### In JavaScript ```ts import { DEFAULT_LOOK, isYoujLook, LOOKS, lookById, looks, useYoujLook } from '@youj/core' LOOKS // ['classic', 'bezel'] looks // the same, each with a name and a one-line description, for a picker lookById('bezel').name // 'Bezel'; an unknown id falls back to Classic isYoujLook(localStorage.getItem('look')) // checks a stored or linked value DEFAULT_LOOK // 'classic' useYoujLook() // the look in effect where it's called ``` ## The stylesheet From the package, there's nothing to add: `@youj/react/styles.css` carries Bezel's recipes, and they apply wherever `data-yj-look="bezel"` is written. From the registry, Bezel's recipes are an item of their own, `youj-look-bezel`, imported right after `ui.css`. `npx @youj/cli init --look bezel` copies it with the core, and a [Create](https://www.youj.dev/create) link in Bezel (`look=bezel`) installs it with the frame: ```bash npx @youj/cli add youj-look-bezel ``` ```css title="app/globals.css" @import 'tailwindcss'; @import '../styles/youj.css'; @import '../styles/ui.css'; @import '../styles/bezel.css'; ``` ## Component by component | Component | In Bezel | | --- | --- | | Card | The head is one line: a larger title, its description as a muted "/ subtitle" (under the title on a narrow card), and a disc at the end. The head, each content block and the footer are sections split by full-bleed hairlines, each a step lighter than the one above, so the footer is the lightest. Text in the head's action, and at the footer's start, reads as a mono capital status line. `variant="inset"` puts everything under the head in a well at the concentric radius. | | Eyebrow, field labels | The short labels over figures, groups and sections (`Eyebrow`, or any element with `data-slot="eyebrow"`) and a field's label over its control are mono capitals, small and tracked. In Classic an `Eyebrow` keeps whatever classes you give it. | | Button | Pills. The primary takes the theme's action fill (the accent in every shipped style; `actions: 'ink'` leaves the accent to state); secondary is a raised filled pill; icon buttons are discs. | | Input, Textarea, Select, Combobox, NumberInput | Filled wells with about 12px corners on the field border (3:1 in both modes), a white field in light mode. Focus keeps the accent ring, and an invalid field keeps its danger edge. | | Tabs, ToggleGroup, SegmentedControl, with `variant="pill"` | A recessed track with the choice as a key that glides: white on a ring at the field border's strength in light (3:1 against the track), a clearly lighter step in dark. The default underline look is not restyled. | | Badge | Soft tinted pills, a raised neutral chip for `secondary`, and a status dot (`BadgeDot`, first inside the badge). | | Switch | Flatter and wider: a gray knob in a recessed track when off, a light knob on the accent when on. | | Checkbox, Radio | Soft squares in a filled well; the radio stays round. | | Table, data tables | Dense rows on hairlines, no zebra, mono capital column heads, and figures in mono at the end of their column. | | StatCard, Counter | Figures in mono. A stat's label is a mono eyebrow, its unit and percent sign are small and muted, and a currency's cents are muted. | | Dialog, Popover, menus | A slightly lifted surface at a larger radius. A dialog's head sits on a hairline, and its footer is a band of pill actions. | | Progress, Slider | A thin recessed rail. Progress fills in a neutral ink, keeping the accent for emphasis (`tone="accent"`), with its label as a mono eyebrow and its value in mono; the slider's range is the accent and its thumb an ink disc. | | Gauge, SegmentedMeter, LimitMeter, HistogramRange, RefillMeter, DistributionStrip, HistoryStrip, ForecastBand | Labels are eyebrows in the theme's label voice and figures are mono. The gauge's band word is a mono capital in its own ink. | | Kbd, Avatar | Keys in mono on a raised chip, and grouped avatars ringed in the surface. | ## Building a component Design it in Classic first, then check it in Bezel. Compose the kit's own parts (`Card` and its sections, `Eyebrow`, `Button`, `Badge`, the fields) so the rules above reach it without any work, and give any new part a `data-slot` of its own so a look can find it. A part the kit doesn't have yet gets its Bezel recipe beside the others, keyed on that slot. In Classic, the default, it must render cleanly on its own: nothing it needs to be legible may exist only in Bezel. ## Overriding a recipe Your `className` wins, as in shadcn. A component writes the classes a recipe also sets with an `own:` variant, which files them in Tailwind's `utilities.own` layer; the recipes sit in `utilities.style` above it; every other utility, yours included, sits directly in `utilities`, above both. So the recipe restyles the component, and a plain utility of yours on the same property beats the recipe: ```tsx … ``` A fill you set on a `Button` also drops Bezel's sheen, so it shows as given. To restyle a part everywhere, write a selector on its `data-slot` in your own CSS (unlayered CSS beats every layer): ```css title="app/globals.css" [data-yj-look='bezel'] [data-slot='card'] { border-radius: var(--radius-lg); } ``` > **Previews on this site:** Previews are built in the Clean style, in Classic, unless you pick another: with the style switch in a component preview's toolbar (that preview only) or in a block preview's. The site's own chrome stays Classic, and a [Create](https://www.youj.dev/create) link carries a style of its own. --- # Backdrops > Painted, grainy grounds to show your blocks on, each with a theme preset drawn from the same colors. Source: https://www.youj.dev/docs/backdrops Some blocks in these docs stand on a backdrop: a soft, grainy ground where the card is the only sharp thing, the way a launch page would show it. Each backdrop has a light and a dark version, each designed for its mode, and a preset of the same name. The preset's accent, neutrals and chart colors come from the same combination as the backdrop, so the block and its ground read as one scheme. Switch the site to dark to see the other side. Palettes drawn from Sanzo Wada's *A Dictionary of Color Combinations* (1933–34). | Backdrop | Made with | Wada combination | Preset | Shown under | | --- | --- | --- | --- | --- | | Meadow | Code: a meadow under sky, panned so the grass streaks | No. 294: Sulpher Yellow, Cream Yellow, Cossack Green, Salvia Blue | `meadow` | [Metric chart](https://www.youj.dev/docs/blocks/trends-and-comparisons#metric-chart) | | Sandstone | Code: broad dunes lit from the top left, with a fine suede grain | No. 279: Raw Sienna, Vinaceous Cinnamon, Ecru, Dark Tyrian Blue | `sandstone` | [Breakdown](https://www.youj.dev/docs/blocks/trends-and-comparisons) | | Cinnabar | Code: near-black glowing into English red, crossed by soft bands | No. 190: Ivory Buff, English Red, Black | `cinnabar` | [Retention](https://www.youj.dev/docs/blocks/trends-and-comparisons#retention) | | Umber | Code: a dusk from a pale sky to umber, with a coarse stipple | No. 160: Sulphine Yellow, Pale Raw Umber, Dark Medici Blue | `umber` | [Orders](https://www.youj.dev/docs/blocks/tables-and-lists#bulk-actions) | | Seaglass | A photograph: a long-exposure sea, softened and graded | No. 302: Cream Yellow, Ecru, Nile Blue, Antwarp Blue | `seaglass` | [KPI cards](https://www.youj.dev/docs/blocks/trends-and-comparisons#kpi-cards) | | Heather | A photograph: heather on a moor, softened and graded | No. 162: Old Rose, Rainette Green, Lilac | `heather` | [Goals](https://www.youj.dev/docs/blocks/meters-and-limits#goals) | In a block's preview, the Appearance menu still switches the block to any other preset; the backdrop stays. ## Use one The ones drawn in code ship as [Atmosphere](https://www.youj.dev/docs/components/atmosphere), in `@youj/motion/backgrounds`. Put it in a positioned section behind your card, and wear the preset of the same name: ```tsx import { meadow, YoujProvider } from '@youj/core' import { Atmosphere } from '@youj/motion/backgrounds' export function Launch() { return (
) } ``` It follows light and dark on its own, holds one still frame under reduced motion, and is a CSS gradient with grain where WebGL2 isn't available. Seaglass and Heather are photographs, so they stay on this site; their presets ship like the others. To do the same with a photograph of your own, soften it and grade it toward the preset's colors, keep it small (these are 2400 × 1500 WebP files of about 20 KB), and lay it behind the card as a background image. ## The presets Meadow, Sandstone, Cinnabar, Umber, Seaglass and Heather are full presets, exported by their ids from `@youj/core` and listed in [Theming](https://www.youj.dev/docs/theming#presets) with the others. Each is its palette in the Clean style (Inter, rounded corners, precise motion), and each passes `checkTheme` in light and dark: text at 4.5:1 on every surface, chart series at 3:1, with neighboring series apart for readers with red-green color blindness. Wada's swatches were moved in OKLCH only as far as those checks needed, keeping their hues. ## Credits The palettes are Sanzo Wada's, from *A Dictionary of Color Combinations* (1933–34). Only the color data was used, from the community dataset [mattdesl/dictionary-of-colour-combinations](https://github.com/mattdesl/dictionary-of-colour-combinations). The photographs are used under the [Unsplash License](https://unsplash.com/license): | Backdrop | Photograph | Photographer | | --- | --- | --- | | Seaglass, light | [Calm sea under golden clouds](https://unsplash.com/photos/calm-sea-under-golden-clouds-ywnnwzcdR5o) | [Jonathan Bean](https://unsplash.com/@jonathanbean) | | Seaglass, dark | [Body of water](https://unsplash.com/photos/body-of-water-OSKCUpGVXv4) | [Regine Tholen](https://unsplash.com/@designbytholen) | | Heather, light | [A field of heather with the sun behind it](https://unsplash.com/photos/a-field-full-of-purple-flowers-with-the-sun-shining-in-the-background-_YIhjRB16aU) | [Jisca Lucia](https://unsplash.com/@wanderlandjournal) | | Heather, dark | [A field of heather](https://unsplash.com/photos/a-field-of-purple-flowers-p_RHVffaclk) | [Chiara Wolf](https://unsplash.com/@wanderwolves) | --- # Figma kit > Build and update a Youj kit in Figma from the same tokens and icons as the packages, plus every component in Youj. Source: https://www.youj.dev/docs/figma Use the development plugin to build a Youj kit in your Figma file. It creates theme variables, text and effect styles, icons and components from the package sources. Run it after an update to refresh the kit while keeping your instances linked. [Download the Figma plugin](https://www.youj.dev/downloads/youj-figma-plugin.zip) (.zip, for Figma desktop). ## Requirements - **The Figma desktop app.** Development plugins can only be imported and run there, not in the browser. - **Edit access to a design file.** A new file in your Drafts is best: the kit takes over its empty first page. - **Any plan.** Starter (free) works too; see [Free and paid plans](#free-and-paid-plans) for what changes. - **No fonts to install for the UI.** The kit sets type in Basalt's Inter and JetBrains Mono, which Figma ships. If one is missing it falls back to Roboto and Roboto Mono, then to the nearest family you have, and says so on the cover. The Youj Display styles need Youj Display installed; without it they fall back to Geist. ## Install ### Open a design file In Figma desktop, create a new design file in your Drafts. ### Import the plugin Unzip the download, then right-click the canvas (or open the main menu) and choose **Plugins → Development → Import plugin from manifest…**, and pick `manifest.json`. You only do this once. Keep the folder where it is: Figma loads the plugin from next to the manifest. ### Build the kit **Plugins → Development → Youj kit → Build or update the kit.** A panel lists each step as it runs. A clean build closes it and lands you on the cover; if a step fails, the panel stays open with what failed, and the rest of the kit is still built. A new file is built in the default look: the **Classic** look on **Basalt**, dark. ### Switch look **Youj kit → Style** (the plugin's menu says Style, but it picks the look) chooses how the components are built, as [`look`](https://www.youj.dev/docs/looks) does in code: **Classic** (the default: hairline panels, outlined fields, underline tabs) or **Bezel** (card heads with a “/ subtitle” and a disc, sections stepping lighter, mono capital eyebrows, filled field wells, segmented tracks with a raised key, pill actions with an ink primary). It rebuilds the kit in that look. The component sets are redrawn in place, so your instances stay linked and follow the new anatomy, and later builds keep the look you chose. ### Switch theme **Youj kit → Theme** picks any preset in light or dark: Basalt (the default, in dark), Graphite, Terminal, Brutal, or one of the presets that match the [backdrops](https://www.youj.dev/docs/backdrops): Meadow, Sandstone, Cinnabar, Umber, Seaglass and Heather. ## What it builds | Page | What's on it | | --- | --- | | Cover | The lockup on its grid, and a report of the build: your plan, the look, the collections it made, any font fallback | | Color | Every theme in light and dark, then the palette and the ui names, bound to variables | | Type | Text styles in Inter and JetBrains Mono, with sizes bound to the scale, among them Bezel's mono capital eyebrow and card title and the site's Youj Display steps (they set in Geist until the brand font is installed) | | Spacing & radius | Spacing, radius, control heights, elevation and focus | | Motion | Durations, easings, springs and staggers for each personality | | Icons | Every icon as a component; the ones that switch states as sets with a `State` property | | Illustrations | The isometric scenes as static vectors, grouped by category, each in Basalt light and dark | | Components | A set for every component in the library, with the code's props as variants, in the look you chose: Button, Toggle and Toggle group; Input, Textarea, Select, Field, Checkbox, Radio, Switch, Slider; Badge, Avatar, Kbd, Separator, Skeleton, Progress, Alert, Banner, Card, Table row; Tooltip, Popover, Dropdown menu, Dialog, Toast; Tabs, Pagination, Breadcrumb, Sidebar item; Gauge, Segmented meter, Limit meter, Share bar, Treemap and Histogram range (static examples), Filter chips; Data grid (a representative frame: toolbar, head, rows at three densities, pager); and every other component: pickers, overlays and menus, layout and navigation, charts, meters and media, with charts and big composites as static examples | | Blocks | A few blocks, captured from Storybook in Basalt dark and Bezel for reference | Variables carry the CSS they mirror as their code syntax (`var(--color-canvas)`), so Dev Mode reads as the code. Bezel's own colors and shapes (`bezel/well`, `bezel/key`, `bezel/pill`…) are read from its stylesheet, per theme and mode, like the rest. ### Themes Every preset is in the kit, in light and dark, with the colors the contract sets per mode (the `{ light, dark }` accent, status and chart values, and each preset's own chart palette) as the Light and Dark modes of the Color collection: | Preset | Palette and style | | --- | --- | | Basalt (the kit's default, in dark) | The Youj palette in the Clean style: Inter, rounded rectangles, sentence-case labels | | Graphite | The same as Basalt under its former light-default name | | Terminal | The Youj palette in the Terminal style: monospace throughout, square corners, tight rows | | Brutal | The Youj palette in the Brutal style: ink outlines, hard offset shadows, square corners, mono capitals | | Meadow, Sandstone, Cinnabar, Umber, Seaglass, Heather | A Sanzo Wada palette each, in the Clean style | > **Illustrations are static:** A scene paints itself from the theme at run time, which Figma can't do. The Illustrations page carries the colors the docs' SVG export gives for Basalt, light and dark; it doesn't re-theme with the file. > **Motion lives in code:** Icons are drawn at rest and component states as still frames. The presses, springs and morphs play in the code, and on this site. A Figma Community file isn't published yet. Use the plugin to try the kit. ## Free and paid plans The plugin checks how many modes your plan allows and lays the same names out to fit. - **Professional and up:** themes are real modes. Pick a frame's Theme and Color modes in the Appearance panel and everything inside re-themes. You can also publish the kit as a team library. - **Starter (free):** one mode per collection, so each theme gets its own collection, and **Youj kit → Theme** re-points the file. In a team file, which allows 3 pages, the kit folds into Youj, Icons (with Illustrations) and Components. Drafts have no page limit. Move the file to a paid plan and run the plugin again: the per-theme collections fold back into modes, and nothing is duplicated. ## Troubleshooting ### “Import plugin from manifest…” is missing You're in Figma in the browser. Development plugins need the desktop app: download it from figma.com/downloads, open the file there and import again. ### A page is empty, or says it “isn't built yet” The build stopped before it reached that page: the plugin was closed, or Figma was. Run **Build or update the kit** again. It picks up in place and doesn't duplicate anything. ### A page says it “didn't build” That step failed, and the note on the page says why; every other page was still built, and the cover lists what went wrong. **Copy report** in the panel copies the whole build report. For more detail, open **Plugins → Development → Show/Hide console** and run the plugin again. ### Figma seems busy for a while The icons, components and the illustration scenes make the kit tens of thousands of layers, so a build takes a few minutes, longer on a slow machine. Keep the file open until the panel says it's done. ### The type isn't in Inter Inter or JetBrains Mono wasn't available, so the kit fell back to Roboto and Roboto Mono, or the nearest family it could load. The cover's build report names it. Figma ships both, so this usually means a custom font setup; install them and run the plugin again. ## Related - [Theming](https://www.youj.dev/docs/theming): The presets the kit draws, and themes of your own. - [Icons](https://www.youj.dev/icons): Every icon, with its motion. - [AI & MCP](https://www.youj.dev/docs/mcp): The kit as tools for your AI coding tool. --- # Youj vs other libraries > How Youj relates to shadcn/ui, Magic UI, Aceternity UI, HeroUI and Kibo UI, and when each is the better fit. Source: https://www.youj.dev/docs/comparison Youj is built on shadcn/ui, and it sits among several good React libraries. This page sets out what Youj ships and how it relates to five others. For the others it states only well-known public facts, and they change often, so check their docs for the current picture. As of October 2026. ## What Youj ships | | Youj 0.1 | | --- | --- | | Components | UI components on Base UI, in shadcn's `base-nova` style, and motion components: primitives, effects, interactions, backgrounds and players | | Blocks | Cards, sections and screens, copied in as source | | Icons | Animated icons in `@youj/icons`, whose state icons morph between their two states, and a few static brand marks | | Illustrations | Illustration scenes in `@youj/illustrations`, the kit they're drawn with, and a builder for your own | | Motion system | Tokens for duration, easing, springs and stagger, three motion personalities, a motion budget and overlay presets that reverse mid-flight. See [Motion](https://www.youj.dev/docs/motion) | | Reduced motion | The provider follows the system setting. Each animated component has an instant or still alternative, described on its page | | Sound | Optional interface sounds in `@youj/sound`, silent until you add its provider. See [Sound](https://www.youj.dev/docs/sound) | | Themes | Presets, each tuned for light and dark, or your own derived from a few inputs. `checkTheme` holds every color pair to WCAG AA, and the CLI and MCP server refuse a theme that misses it. See [Theming](https://www.youj.dev/docs/theming) | | Styles | Classic (the default) and Bezel, with any theme. See [Looks](https://www.youj.dev/docs/looks) | | Languages | Built-in strings in a set of locales besides English, and right-to-left layouts | | How you get it | npm packages, or source through `npx @youj/cli add` or the [shadcn CLI](https://www.youj.dev/docs/shadcn-registry) | | CLI | `init`, `add`, `create`, `doctor` and `mcp`. See [CLI](https://www.youj.dev/docs/cli) | | AI tools | An [MCP server](https://www.youj.dev/docs/mcp) with the docs, props, icons and install commands, and `llms.txt` | | Figma | A [plugin](https://www.youj.dev/docs/figma) that builds the kit in Figma from the same tokens | | License | MIT | ## At a glance | Library | How you get it | Built on | Focus | | --- | --- | --- | --- | | Youj | npm packages, or source through its CLI or the shadcn CLI | Base UI, Tailwind CSS v4, Motion | A full set with one motion system, plus icons, illustrations, sound and themes | | shadcn/ui | Source copied in with the shadcn CLI | Radix UI or Base UI, Tailwind CSS | The foundation components and the registry format others build on | | Magic UI | Source through the shadcn CLI | Tailwind CSS, Motion | Animated components and effects for landing pages | | Aceternity UI | Source copied from its site or through the shadcn CLI | Tailwind CSS, Motion | Animated, visual sections for marketing pages | | HeroUI | npm packages | React Aria, Tailwind CSS | A packaged component library, formerly NextUI | | Kibo UI | Source through its own CLI or the shadcn CLI | shadcn/ui, Tailwind CSS | Larger composable components on top of shadcn/ui | ## shadcn/ui shadcn/ui is Youj's base, and Youj stays grateful to it (see [Credits](https://www.youj.dev/docs/contributing#credits-and-licenses)). It gives you well-made components as source you own, a choice of Radix UI or Base UI underneath, and the registry format that Youj and many others publish to. It animates with Tailwind utility classes. Youj keeps shadcn's component names and, where Base UI allows, its props, and adds a motion system the whole set shares, npm packages next to copy-in, animated icons, illustrations, sound, themes derived and checked for contrast, two component styles and `npx @youj/cli doctor`. Moving over from a Radix-based shadcn style changes some props (`asChild` becomes `render`, menu items take `onClick`, `Select` takes `items`): [Coming from shadcn/ui](https://www.youj.dev/docs/guides/shadcn) walks through it and lists every change. Choose shadcn/ui alone when you want the smallest starting point and plan to design the motion and the rest yourself. ## Magic UI Magic UI is a set of animated components and effects, often used on landing pages, installed through the shadcn CLI. It is open source, with paid templates on top. Its items use shadcn's color variables, which Youj's stylesheet maps onto Youj tokens, so a section from Magic UI can sit in a Youj app. The difference is scope: Magic UI focuses on eye-catching effects, while Youj's motion runs through every component, from a button press to a dialog, with shared tokens and a reduced-motion state for each. ## Aceternity UI Aceternity UI offers animated, visual components for marketing pages. You copy them from its site or install them through the shadcn CLI, and it sells paid templates and blocks alongside the free components. Like Magic UI, it is a good source of standout hero and feature sections. Youj covers the application side too: forms, data tables, overlays, navigation, app frames and the blocks built from them. ## HeroUI HeroUI, formerly NextUI, is a packaged component library built on React Aria and Tailwind CSS. You install it from npm and import its components, as you can with Youj. It isn't built on shadcn/ui, so its components don't share shadcn's names or variables, and using it beside a shadcn-based set such as Youj means two sets of tokens to keep in step. Choose between the two by the primitives you prefer (React Aria or Base UI) and by what ships with each. ## Kibo UI Kibo UI is an open-source set of larger composable components, such as a Kanban board and a Gantt chart, built on top of shadcn/ui. You add them with its own CLI or the shadcn CLI. It complements a shadcn-based app with components that shadcn/ui doesn't have. Youj's set is broader and animated throughout. Where Kibo has a component Youj doesn't, it can be added beside Youj through the same shadcn CLI. ## When Youj fits - You want motion that's consistent across every component, with reduced motion handled for you. - You want versioned npm packages, source you own, or both in one app. - You want icons, illustrations, themes and sound in the same design language as the components. Another library may fit better when you want only a minimal base (shadcn/ui), a single standout landing section (Magic UI, Aceternity UI), React Aria underneath (HeroUI), or one specific complex component (Kibo UI). Youj is also new, at version 0.1, while the others have been in use longer. ## Related - [Installation](https://www.youj.dev/docs/installation): Set Youj up in your app. - [Coming from shadcn/ui](https://www.youj.dev/docs/guides/shadcn): Move a shadcn/ui app onto Youj. - [Using with shadcn](https://www.youj.dev/docs/shadcn-registry): Add Youj items with the shadcn CLI. - [Credits](https://www.youj.dev/docs/contributing#credits-and-licenses): The work Youj builds on. --- # Versioning and releases > What a Youj version number promises while it's in alpha, what counts as a breaking change, how settled each part is, and where each release's changes and upgrade guides are. Source: https://www.youj.dev/docs/versioning Youj is in alpha: ready to try and build with, but the API will still change before 1.0. Every package is at 0.x, and it goes to 1.0 once the API has settled. Until then, this page is the promise. ## What a version means Youj follows [semantic versioning](https://semver.org) the way 0.x projects do. The first number stays 0 until 1.0, so the second one does the work a major version would. | Release | For example | What's in it | | --- | --- | --- | | Patch | 0.1.1 → 0.1.2 | Fixes and additions. Nothing you use breaks, so take it any time. | | Minor | 0.1.2 → 0.2.0 | May break things. Every breaking change is listed, with how to migrate. Read the notes first. | | Major | 1.0.0 | The API is settled. From then on only a major release breaks anything. | The range a package manager writes, such as `^0.1.2`, already stops before 0.2.0, so a plain install or update never takes a breaking release by surprise. All the packages release together at one version: `@youj/react`, `@youj/core`, `@youj/motion`, `@youj/icons`, `@youj/illustrations`, `@youj/loaders`, `@youj/miniatures`, `@youj/sound`, `@youj/mcp` and the CLI, `@youj/cli`. Keep them on the same one; `npx @youj/cli doctor` tells you when they drift. ## The alpha promise While Youj is in alpha (0.x): - **Breaking changes come only in a minor release**, never in a patch. - **Every one is announced** under Breaking in the [changelog](https://www.youj.dev/changelog), with before and after code. A release that breaks something you're likely to use gets an [upgrade guide](https://www.youj.dev/docs/versioning#upgrade-guides). - **Deprecation comes first, wherever it's cheap.** When something is replaced rather than removed (a renamed prop, a renamed value), the old name keeps working for at least one more minor release. The docs mark it. Then it goes. - **Parts labeled stable always get that deprecation step.** Experimental ones may change in any minor release without one. See [stability labels](#stability-labels). ## What counts as breaking | Change | Breaking? | | --- | --- | | Removing or renaming an export, a component part or a prop | Yes | | Narrowing a prop: a value it no longer accepts, an optional prop made required | Yes | | Changing a default: a prop's, a token's, a provider setting's | Yes | | Removing or renaming a CSS variable, a token, or a `data-slot` or `data-*` state name your styles or tests can select | Yes | | A visible change to size or layout: a control's height, a component's padding or default width | Yes | | Raising a peer's minimum (React, Tailwind CSS, Motion) | Yes | | Renaming a registry item, or changing the paths its files are written to | Yes | | Adding an export, a prop, a value, a token or a component | No | | A refined color, shadow or motion within the same tokens | No, noted under Changed | | Fixing behavior to match the docs, including accessibility fixes | No, noted under Fixed | | Widening a peer range | No | When in doubt, it's treated as breaking. ## Upgrading between releases Moving from one 0.x release to the next is a minor upgrade, so it can break things. The [releases page](https://www.youj.dev/docs/versioning#upgrading) has the commands. In order: 1. Read the Breaking section of every release you're skipping over, in the [changelog](https://www.youj.dev/changelog), and follow its upgrade guide if it has one. 2. Upgrade every Youj package to the same version, then run `npx @youj/cli doctor`. Your `^0.1.2` range won't take 0.2.0 by itself: change it on purpose. 3. Fix what the changelog says, and what TypeScript reports. A deprecated API still works for at least one minor release, so you can upgrade first and migrate after. 4. Copied-in items stay as they are. Run `npx @youj/cli add --dry-run` to see what the new version would change, and add it again only when you want it. On the `next` channel, prereleases such as `0.2.0-next.1` may change before the release ships. Pin the exact version while you test one. ## Copy-in code is yours What `npx @youj/cli add` copies into your app is your code: an upgrade never touches it. What changes between versions is what the next `youj add` writes. - **Copy the version you run.** Each release publishes its registry items under its version number as well as the latest, so a 0.1 CLI copies 0.1's items. The CLI uses its own version, so run it at the version of your packages (`npx @youj/cli@0.1.2 add …`); `--latest` takes the latest items instead. - **Updating a copied item is a choice.** Run `npx @youj/cli add ` again and review the diff before you keep it, as you would any upstream change. A `youj diff ` command, to show what changed upstream since you copied it, is planned. - **Blocks update the same way.** A block is copy-in source: once added it's yours, and later changes on the site don't touch your copy. Because `npx @youj/cli add` copies the items of the CLI's own version, which stay archived per release, running it never breaks an existing install. Notable block changes, renames and removals are listed in the changelog. - **The changelog covers registry items too.** A breaking change to a block's or component's source is listed like one to a package. ## Stability labels Youj is in alpha, so every component, block, loader and package entry is alpha unless it says otherwise. Only the riskiest parts carry a label: **experimental**. You'll find it in the header of the part's docs page, and the [MCP server](https://www.youj.dev/docs/mcp) states each part's label in its answers. | Label | What it promises | | --- | --- | | **Alpha** | The default, so it has no badge. Ready to use and tested. Its API may still change in a minor release, announced in the changelog with a migration note, deprecated first where that's cheap. | | **Experimental** | Still being shaped. It may change or go in any minor release without a deprecation. Pin the exact version if you depend on it. | A third label, **stable**, is kept for the road to 1.0. Nothing carries it yet: a part moves up to stable when its API has held through a release or two with no changes asked for. A stable item depends only on stable items, and a stable entry point exports only stable items; a test enforces both. A stable part always gets the deprecation step before a breaking change, even while Youj is 0.x. How a label is decided: - **Alpha** is the default for everything that's tested: the component package (`@youj/react`, whose names are still being aligned across components), blocks, motion, loaders, icons, illustrations, sounds, the CLI and the MCP server. - **Experimental** is for what's new or not yet covered by tests: the Data grid block, Presentation, the voice orb, the audio recorder, the WebGL backgrounds, the Lottie player, the illustration builder and Create. Moving up never happens silently, and neither does moving down: either is a changelog entry. The labels live in one file, `stability.json` at the root of the repository: a default per kind of item, the items that differ from it, and a label for each package entry. ## Deprecations A deprecated API keeps working until the release named for its removal, at least one minor release later. Three things mark it: - **In the docs:** its props table strikes it through and says what replaces it, and its page says so in prose: > **An old prop is deprecated since 0.1.0, and goes in 0.3.0:** Use `variant` instead. Until 0.3.0 the old prop keeps working. - **In the changelog:** under Changed in the release that deprecates it, under Breaking in the one that removes it. Mark the old path `@deprecated` in its JSDoc; the docs and the API reference pick it up. ## Release channels | Channel | Install | What it is | | --- | --- | --- | | `latest` | `pnpm add @youj/react` | The current release. What everyone gets by default. | | `next` | `pnpm add @youj/react@next` | Prereleases (`0.2.0-next.1`) for testers, ahead of a minor release. They may change before it ships. | With the CLI, `npx @youj/cli@next init` sets an app up on the prerelease. Put every Youj package on the same channel. ## Supported versions | | Supported | | --- | --- | | React | 19 | | Motion (`motion`) | 13.4.4 or later, and 14 | | Base UI (`@base-ui/react`) | 1.8 and 1.9 | | Tailwind CSS | 4 | | Browsers | Chrome 123, Safari 17.5 and the current Firefox, or later (Edge follows Chrome). Below that, `light-dark()` and other CSS the components rely on aren't supported. A theme's own derived colors need Chrome 120, Safari 18 or Firefox 128; Safari 17.5 keeps Graphite's (see [Theming](https://www.youj.dev/docs/theming)). | | Node.js | 22 or later, for the CLI and the MCP server | Only the latest minor release gets fixes while Youj is 0.x. After 1.0, the previous major will get security fixes for a time we'll state when it ships. ## Releases and upgrade guides Every Youj package releases together, at one version. ### What changed - **[The changelog](https://www.youj.dev/changelog)** has every release, newest first, with its Breaking, Added, Changed and Fixed changes. Subscribe to its [feed](https://www.youj.dev/changelog.xml) to hear about new ones. - **[CHANGELOG.md](https://github.com/youj-dev/ui/blob/main/CHANGELOG.md)** in the repository is the same record, as one file. - **Each package's own `CHANGELOG.md`** (`packages/ui/CHANGELOG.md` for `@youj/react`, and so on) lists every change to it, one line each, written when the release is versioned. ### Upgrading Upgrade every Youj package at once, to the same version: ```bash pnpm up "@youj/*" --latest npx @youj/cli doctor ``` `youj doctor` checks the packages agree and the setup still holds. Before a minor upgrade, read its Breaking section in the changelog and follow its upgrade guide, if it has one. Deprecated APIs keep working for at least one minor release, so you can upgrade first and migrate after. Code you copied in with `npx @youj/cli add` doesn't change when you upgrade. To take an item's new version, add it again and review the diff before you keep it. To try a release before it ships, install the `next` channel: `pnpm add @youj/react@next`, with the other Youj packages on `@next` too. ### Upgrade guides A minor release that breaks something you're likely to use gets a guide here: what changed and why, before and after code, the codemod to run if there is one, and the steps to do by hand. There are none yet: 0.1.0 is the first release. --- # Contributing and credits > Work on Youj itself: how the monorepo is laid out, how to run it, the checks a change passes and how to propose one, and the open-source projects Youj builds on. Source: https://www.youj.dev/docs/contributing Youj is built in one pnpm monorepo: one package per product, sharing one design language and released together at one version. This page is for changing Youj itself. To use it in an app, start with [Installation](https://www.youj.dev/docs/installation). ## Set up You need Node.js 22.22 or newer (`.nvmrc` pins what CI runs; the published packages need 22 or later) and pnpm 10 (`corepack enable` installs the pinned version). Clone the [repository](https://github.com/youj-dev/ui), then install from its root: ```bash pnpm install ``` pnpm installs every package and app in the workspace at once, and links them to each other. ## How the repo is laid out | Folder | Package | What | | --- | --- | --- | | `packages/core` | `@youj/core` | Tokens, `YoujProvider` and `useYouj`, themes, labels and the sound cue bus | | `packages/motion` | `@youj/motion` | Motion primitives, transitions, effects, interactions, backgrounds and the Lottie player | | `packages/ui` | `@youj/react` | The accessible components, animated through `@youj/motion`, and the blocks | | `packages/loaders` | `@youj/loaders` | The loading animations | | `packages/miniatures` | `@youj/miniatures` | Interactive isometric objects, one entry each | | `packages/icons` | `@youj/icons` | The animated icon set and its engine | | `packages/illustrations` | `@youj/illustrations` | The isometric kit, the scenes, and the illustration builder's scene format and renderer | | `packages/sound` | `@youj/sound` | Interface sounds, the Glass pack and custom packs | | `packages/cli` | `youj` | The [CLI](https://www.youj.dev/docs/cli) | | `packages/mcp` | `@youj/mcp` | The [MCP server](https://www.youj.dev/docs/mcp) | | `packages/figma` | `@youj/figma` (private) | The Figma plugin that builds the [Figma kit](https://www.youj.dev/docs/figma) from the real tokens | | `apps/docs` | `docs` | This site, which also hosts the registry at `/r` | | `apps/promo` | `promo` | The promo film, rendered from the real components | `scripts/` holds the workspace's own tools (the import check, the release, the consumer smoke tests), and `tests/` the Playwright suites that run over Storybook. Each package has a `CONTRIBUTING.md` with its own rules. Two rules hold across all of them: - **Dependencies point one way.** Motion, loaders, icons, illustrations and sound build on core; miniatures needs only React; ui builds on core, motion and icons. Never the reverse: core imports no other Youj package, and motion imports no icon or scene. `pnpm check:imports` fails on a package importing one it doesn't declare. - **Source mirrors the install path.** The `src/` folders of motion, icons and illustrations match where `youj add` copies their files (`lib/youj/`, `components/youj/icons/`, `components/youj/graphics/`), so copied files keep their relative imports. ## Run it ```bash pnpm storybook # http://localhost:6006 pnpm -F docs dev # http://localhost:3100 ``` **Storybook** is where components are built and reviewed. One Storybook at the root shows every package, and its toolbar sets the theme (light or dark), the preset, reduced motion, the speed and the text direction. Check every change there in both themes, with reduced motion, and at more than one speed. **The docs site** runs its generate step first (props tables, the registry, the gallery data), so the first start takes a moment. [apps/docs/README.md](https://github.com/youj-dev/ui/blob/main/apps/docs/README.md) explains how it's built. ## Scripts Run these from the repository root: | Script | What it does | | --- | --- | | `pnpm typecheck` | Typechecks every package and app. | | `pnpm test` | Runs every package's tests. | | `pnpm verify` | The full gate: the import, changeset and public API checks, then each package's own `verify` (typecheck, token lint, generated-file and registry checks, tests), then the package check. | | `pnpm check:imports` | Fails on a package importing one it doesn't declare. | | `pnpm check:api` | Fails when a package's public API (its exports, signatures, defaults and props) changed and its snapshot in `api/` didn't. `pnpm api:update` rewrites the snapshot. | | `pnpm check:changesets` | Fails on a changeset that asks for a major bump while the packages are 0.x, which would publish 1.0.0. | | `pnpm check:packages` | Builds and packs every published package as a dry run (no tarball), and fails on a broken build or a manifest entry missing from the package. | | `pnpm build-storybook` | Builds Storybook as a static site. | | `pnpm test:a11y` | Runs the accessibility checks (axe, and touch target sizes) over the stories. | | `pnpm test:visual` | Runs the visual regression suite in Docker. `pnpm test:visual:update` refreshes its baselines. | | `pnpm smoke` | Packs the packages, installs them into fresh apps (Next.js and Vite on React 19), builds them and renders them in a browser. | To run a script for one package, filter by its name: ```bash pnpm -F @youj/motion test pnpm -F @youj/react verify pnpm -F @youj/cli test pnpm -F docs typecheck ``` `verify` is what CI checks, so run it for each package you changed before you open a pull request. ## Conventions ### Tokens only - **Colors are semantic tokens**: `bg-canvas`, `bg-surface`, `text-fg`, `text-fg-muted`, `border-line`, `bg-accent` and the rest. No raw colors and no stock Tailwind palette. - **Sizes come from the theme**: heights from `h-control*`, type from `text-ui*`, and radius and density from the theme contract. - **A component's classes that a style recipe also sets carry `own:`**, so an app's `className` beats the recipe. Don't write them by hand: `node packages/ui/scripts/codemods/own-variant.mjs` writes them (`ui:add` runs it on new components), and `lint:tokens` checks them. - **Timing comes from the [motion tokens](https://www.youj.dev/docs/motion/tokens)**: `useYouj()` in JavaScript, `yj-duration-*` and `ease-*` in CSS. Never a hard-coded duration or curve, and never `transition-all` or a stock `duration-*`. `pnpm -F lint:tokens` enforces this, and `verify` runs it. Mark a justified exception on its own line with `// youj-allow: `. ### Motion - Every animation has a designed reduced-motion state that tells the reader the same thing. Loops stop; scroll and pointer effects switch off. - The time scale reaches everything, including `requestAnimationFrame` loops and shaders. - Motion elements are `m.*` from `motion/react`, inside `MotionFeatures`, so apps load Motion's engine on demand. - Animate `transform`, `opacity`, `clip-path`, `filter` and SVG `pathLength` only. Pause offscreen loops and clean up every frame, observer and GL context. ### Components - Accessibility isn't optional: correct roles, full keyboard support, visible focus rings, and live-region announcements where state changes silently. - Inside a package, import with relative paths, never the `@/` alias, so the source works both as a package and as copied files. - A component that needs a reusable motion behavior takes it from `@youj/motion`. A component never animates on its own terms. - A new component starts from shadcn's source through the package's wrapper, `pnpm -F @youj/react ui:add `, which rewrites it to Youj's conventions. [packages/ui/CONTRIBUTING.md](https://github.com/youj-dev/ui/blob/main/packages/ui/CONTRIBUTING.md) lists the steps that finish it. - Don't add dependencies casually. Say in the pull request what you need and why; a heavy one becomes an optional peer with its own entry. ### Building a block A block is a page-sized composition in `packages/ui/src/blocks//.tsx`, built only from public components, and usable without editing its source. Every block follows one API, which its docs page turns into Usage and Props (see [Dashboards](https://www.youj.dev/docs/blocks/dashboards) for one): - **Data** comes in through domain-noun props (`kpis`, `members`, `plans`) typed by block-prefixed interfaces (`DashboardKpi`) in its `data.ts`. The defaults are the demo's data. - **Strings** go through `copy?: Partial`: every visible string, aria-labels and toasts included, with a function for any string that holds a value. - **Theme and locale** come from the app: the block renders `` with `theme` undefined by default, and formats in `useLocale(locale)`, never `'en-US'`. A showcase look goes in the story's `args`. - **Actions** are `on` callbacks that may return a promise; the block shows them pending, then the rejection's message. - **An unwired action** falls back through the block's `demo(': on', ms)`, which plays the demo and warns once in development. A block that simulates live data does it only without its data prop, under a `Demo data` badge (`demoMarker`). - **State the app owns** is a `value`/`defaultValue`/`onValueChange` trio (named, like `range`/`defaultRange`/`onRangeChange`, when there are several). Time comes in as `now` or `today`. - **Lists and loads** take `loading`, `error` with `onRetry`, and `empty`. - **Composition:** `className` and the rest props on the root, named slots (`header`, `actions`, `footer`, `empty`…), `brand` for a product name and logo, and `linkComponent` (default `a`) for links whose `href` comes from the data. - A **With your data** story feeds it other data, and its test proves the props drive the content, `copy` rewrites the text and the callbacks fire. `pnpm -F @youj/react lint:blocks` (part of `verify`) fails on a forced theme, a hard-coded `'en-US'` or a bare `wait()`. A block stays under 400 lines, or keeps its entry and moves its parts to a sibling folder, each under 300. [packages/ui/CONTRIBUTING.md](https://github.com/youj-dev/ui/blob/main/packages/ui/CONTRIBUTING.md) has the full standard. ### Tests and stories - Every component has tests beside it (`.test.tsx`) that check behavior, not implementation details. - Every component has a story (`.stories.tsx`) with at least one story that looks like a real product moment, Controls for its meaningful props, and a reduced-motion story or note. ### Docs and Storybook stay in step Every component, block and example that ships has a docs page as well as a story. `apps/docs/test/parity.test.ts` fails when one is missing, when a page isn't in its folder's `meta.json`, or when a page doesn't follow the rules in [apps/docs/README.md](https://github.com/youj-dev/ui/blob/main/apps/docs/README.md). A component page shows a live preview, the install command, its motion tokens, reduced-motion behavior and a props table generated from the source. ### Generated files Some files are generated from the source, and `verify` fails while they're stale. Regenerate them after these changes: | After you… | Run | | --- | --- | | Add a component or hook, or change its imports | `pnpm -F @youj/react generate` | | Add an icon | `pnpm -F @youj/icons icons:generate` | | Add an illustration scene | `pnpm -F @youj/illustrations generate` | | Change docs, components or icons the MCP server describes | `pnpm -F @youj/mcp generate` | | Change a package's public API: an export, a prop, a default | `pnpm api:update`, and commit `api/` | ## Propose a change - **Found a bug?** [Open an issue](https://github.com/youj-dev/ui/issues/new) with the component, the browser, the steps to reproduce, and what you expected. - **Have a question or an idea?** Start a [discussion](https://github.com/youj-dev/ui/discussions) before writing a large change, so the approach can be agreed first. - **Found a vulnerability?** Report it privately, as the [security policy](https://www.youj.dev/security) describes. Don't open a public issue. - **Speak one of the translated languages?** [CONTRIBUTING.md](https://github.com/youj-dev/ui/blob/main/CONTRIBUTING.md#improving-a-language) explains how to fix a language pack. For a pull request: ### Make one change Keep a pull request to one logical change, with its tests, its story and its docs page in the same pull request. ### Describe it for the changelog If it changes a published package, run `pnpm changeset`, pick the bump and write one line for the changelog. The packages are 0.x, so a breaking change is a minor bump and anything else a patch; never pick major. Commit the file it creates in `.changeset/`. A diff in `api/` is the cue: a removed or renamed export or prop, or a changed default, is breaking ([what counts](https://www.youj.dev/docs/versioning#what-counts-as-breaking)). Deprecate first where it's cheap: keep the old name working, mark it `@deprecated` in its JSDoc. ### Check it Run `verify` for each package you changed, or `pnpm verify` for all of them. CI also builds the packages, Storybook and this site, checks the bundle size budgets, runs the consumer smoke tests and compares screenshots. ## Credits and licenses Youj is MIT-licensed. It builds on the open-source projects below, and each is used under its own license. Thank you to everyone who made them. ### Foundations | Project | What Youj uses it for | License | |---|---|---| | [shadcn/ui](https://ui.shadcn.com) | `@youj/react` components begin from shadcn's `base-nova` sources, and copied-in items install through the Youj CLI (`npx @youj/cli add`), which uses the shadcn registry format and installer | MIT © shadcn | | [Base UI](https://base-ui.com) | The accessible, headless primitives behind the components | MIT © MUI | | [Motion](https://motion.dev) | The animation runtime every motion token and component runs on | MIT © Motion Division | | [Tailwind CSS](https://tailwindcss.com) | Styling and the token layer | MIT © Tailwind Labs | | [React](https://react.dev) | Everything | MIT © Meta Platforms | ### Components | Project | Used by | License | |---|---|---| | [TanStack Table](https://tanstack.com/table) and [TanStack Virtual](https://tanstack.com/virtual) | The data table kit and blocks, TreeView, long lists | MIT © Tanner Linsley | | [cmdk](https://cmdk.paco.me) | Command and CommandPalette | MIT © Paco Coursey | | [React Day Picker](https://daypicker.dev) and [date-fns](https://date-fns.org) | Calendar and the date pickers | MIT | | [React Hook Form](https://react-hook-form.com), [Zod](https://zod.dev) and [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | Form | MIT | | [Recharts](https://recharts.org) | Chart | MIT © recharts group | | [Embla Carousel](https://www.embla-carousel.com) | Carousel | MIT © David Jerleke | | [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) | Resizable | MIT © Brian Vaughn | | [class-variance-authority](https://cva.style) | Component variants | Apache-2.0 © Joe Bell | | [clsx](https://github.com/lukeed/clsx) and [tailwind-merge](https://github.com/dcastil/tailwind-merge) | Class names | MIT | | [dotLottie](https://github.com/LottieFiles/dotlottie-web) | The Lottie player (optional peer dependency of `@youj/motion`) | MIT | | [Clack](https://github.com/bombshell-dev/clack) | The prompts in `npx @youj/cli init` | MIT © Nate Moore | | [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) | The [Youj MCP server](https://www.youj.dev/docs/mcp), which bundles it | MIT © Anthropic, PBC | ### Icons The Youj icon set is original work, drawn for Youj, and a geometric check in its test suite compares every icon with Lucide's on each run. `YoujIcon` and `IconSwap` also work with [Lucide](https://lucide.dev) icons, which are used under the ISC license, © Lucide Contributors. The packages don't depend on lucide-react: an app that animates Lucide icons installs it itself. #### Brand marks The brand marks below, the `brand` icons and the Google mark in the auth blocks, are trademarks of their owners, drawn from each owner's official asset and included only to refer to their services, following their published guidelines. They aren't covered by Youj's MIT license. | Mark | Guidelines | |---|---| | GitHub | [brand.github.com](https://brand.github.com/foundations/logo) | | X | [X brand toolkit](https://about.x.com/en/who-we-are/brand-toolkit) | | LinkedIn | [brand.linkedin.com](https://brand.linkedin.com/in-logo) | | Discord | [discord.com/branding](https://discord.com/branding) | | Figma | [Using the Figma brand](https://www.figma.com/using-the-figma-brand/) | | Google (auth blocks' sign-in button) | [Sign in with Google branding guidelines](https://developers.google.com/identity/branding-guidelines) | ### Type and media | Asset | Used for | License | |---|---|---| | [Inter](https://rsms.me/inter) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/) | The docs site, Storybook and the promo film; not shipped in the packages | SIL Open Font License 1.1 | | [Geist and Geist Mono](https://vercel.com/font), [IBM Plex](https://www.ibm.com/plex/) (Sans, Serif and Mono), [Manrope](https://github.com/sharanda/manrope), [DM Sans and DM Mono](https://github.com/googlefonts/dm-fonts), [Plus Jakarta Sans](https://github.com/tokotype/PlusJakartaSans), [Outfit](https://github.com/Outfitio/Outfit-Fonts), [Space Grotesk](https://github.com/floriankarsten/space-grotesk) and [Space Mono](https://github.com/googlefonts/spacemono), [Instrument Serif](https://github.com/Instrument/instrument-serif), [Fraunces](https://github.com/undercasetype/Fraunces), [Newsreader](https://github.com/productiontype/Newsreader), [Source Serif 4](https://github.com/adobe-fonts/source-serif) and [Bricolage Grotesque](https://github.com/ateliertriay/bricolage) | The typefaces [Create](https://www.youj.dev/create) and the theme builder offer, self-hosted by this site (Latin subsets) and loaded only when chosen; an export sets up the ones its theme uses. Not shipped in the packages | SIL Open Font License 1.1 | | [Remotion](https://www.remotion.dev) | Rendering the promo film; not shipped in the packages | Remotion License | Each package ships with its `LICENSE`, and the full third-party notices are kept in `THIRD_PARTY_NOTICES.md` alongside the source. --- # Guides > Follow step-by-step guides to add Youj to your app and build with it. Source: https://www.youj.dev/docs/guides Start with the task you want to complete. These guides show how the components, motion and themes work together. - [Coming from shadcn/ui](https://www.youj.dev/docs/guides/shadcn): Move an app that runs shadcn/ui onto Youj, one component at a time. - [Build an app frame](https://www.youj.dev/docs/guides/build-an-app-frame): Put a sidebar, top bar, account menu and search together into the shell your pages live in. - [Build a marketing site](https://www.youj.dev/docs/guides/build-a-marketing-site): Put a header, hero, features, pricing, testimonials, FAQ and footer together from basic components. - [Light, dark and system in Next.js](https://www.youj.dev/docs/guides/light-dark-and-system): Let people choose a theme, remembered and with no flash on load. - [Composing components](https://www.youj.dev/docs/guides/composing-components): Build a component of your own from Youj's primitives, transitions and icons. - [Translating built-in text](https://www.youj.dev/docs/guides/i18n): Translate every label, announcement and placeholder the components own, with one object. - [Right-to-left layouts](https://www.youj.dev/docs/guides/rtl): Mirror sides, arrow keys, overlays and motion for Arabic and Hebrew from one dir prop. - [Accessibility](https://www.youj.dev/docs/guides/accessibility): What Youj does for WCAG 2.2 AA, what it tests on every change, and what's yours to check. ## In each section Some how-tos belong to one part of the kit, and live with it. - [Theming](https://www.youj.dev/docs/theming): Start from a preset, or bring your own colors, radius, density and motion. - [Motion in your components](https://www.youj.dev/docs/motion/in-your-components): Give your own components Youj timing, so they follow Speed and reduced motion. - [Icons in your components](https://www.youj.dev/docs/icons/in-your-components): Put icons in buttons, menus and fields so they play on the right trigger. --- # Build an app frame > Put a sidebar, a top bar, an account menu and search together into the shell your app's pages live in. Source: https://www.youj.dev/docs/guides/build-an-app-frame An app frame is the part of the screen that doesn't change from page to page: a sidebar for the destinations, a top bar for search and the account, and a content area where each page goes. This guide builds one from Youj's components, in one file you can copy, then shows the ready-made frame parts you can copy in instead. Every piece is a component you can use on its own. The frame is only the way they sit together. | Part | Component | Job | | --- | --- | --- | | Sidebar | [Sidebar](https://www.youj.dev/docs/components/sidebar) | The app's destinations. Collapses to icons, and becomes a sheet on phones. | | Top bar | [App Header](https://www.youj.dev/docs/components/app-header) | The sticky bar over the page: the sidebar's trigger, search, the account. | | Account | [Account Menu](https://www.youj.dev/docs/components/account-menu) | The avatar menu: profile, theme, sign out. | | Search | [Command Palette](https://www.youj.dev/docs/components/command-palette) | The dialog that finds a page or runs an action, opened by the top bar's search button or `⌘K`. | | Page | [App Page](https://www.youj.dev/docs/components/app-page) | The title, actions and gutter of each page, inside the content area. | ## The shell `SidebarProvider` wraps everything and holds whether the sidebar is open. `Sidebar` is the panel, `SidebarInset` is the page beside it, and the top bar goes at the top of the inset. The sidebar's trigger lives in the top bar, so one control opens the sidebar on a desktop and the sheet on a phone. ```tsx title="components/app-shell.tsx" 'use client' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { Suspense, useState, type ReactNode } from 'react' import { AccountMenu, AppHeader, AppHeaderActions, AppHeaderSearch, Separator, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarProvider, SidebarTrigger, } from '@youj/react' import { CommandGroup, CommandItem, CommandPalette } from '@youj/react/command' import { HomeIcon, SettingsIcon } from '@youj/icons' const pages = [ { href: '/', label: 'Home', icon: HomeIcon }, { href: '/settings', label: 'Settings', icon: SettingsIcon }, ] const user = { name: 'Ada Lovelace', email: 'ada@acme.com' } // The one place that reads the URL, so it's the one place that waits for it (see "Dynamic routes", below). function SidebarPages() { const pathname = usePathname() return ( {pages.map(({ href, label, icon: Icon }) => { const active = href === '/' ? pathname === '/' : pathname.startsWith(href) return ( }> {label} ) })} ) } export function AppShell({ children }: { children: ReactNode }) { const router = useRouter() const [searching, setSearching] = useState(false) return ( }> Acme } > {/* an upgrade card, a plan meter… */} setSearching(true)} /> router.push('/sign-in')} /> {children} {pages.map(({ href, label }) => ( { setSearching(false) router.push(href) }} > {label} ))} ) } ``` Wrap the app in it once, in a layout, and give each page an [App Page](https://www.youj.dev/docs/components/app-page): ```tsx title="app/(app)/layout.tsx" import { AppShell } from '@/components/app-shell' export default function AppLayout({ children }: { children: React.ReactNode }) { return {children} } ``` ```tsx title="app/(app)/page.tsx" import { AppPage } from '@youj/react' export default function Home() { return {/* your sections */} } ``` The search button and the palette share `⌘K` (`Ctrl K` elsewhere), so the button shows the shortcut and the palette answers it. Both are in the shell above, one state apart: the button sets `searching`, the palette reads it. ### Which mobile menu Below `md` the sidebar becomes a sheet, and the `SidebarTrigger` in the top bar opens it. With a sidebar in the frame, that is the phone menu: leave `AppHeaderMenu` out, or phones get two hamburgers that open two lists of the same pages. `AppHeaderMenu` is for the other shape of app, with no sidebar: the header carries the navigation (`AppHeaderNav`) on wide screens, and `AppHeaderMenu` holds the same links in a sheet on phones. For primary destinations under the thumb, a [Bottom Tab Bar](https://www.youj.dev/docs/components/bottom-tab-bar) goes with either. ### Dynamic routes `usePathname()` reads the URL, and on a dynamic route (`/projects/[id]`) the URL isn't known until a request arrives. With Next's `cacheComponents` on, a component that reads it must sit inside a `Suspense` boundary, or the build stops on the route. That is why `SidebarPages` is wrapped above: the sidebar's frame still prerenders, and only the list of links waits. The skeleton rows hold its place, so nothing jumps when the links arrive. A sidebar whose links don't depend on the URL needs no wrapper. ## Lay out a page A page that fits on a laptop can push the whole screen sideways on a phone. The cause is almost always the same: a grid or flex item defaults to `min-width: auto`, so it won't shrink below its content. One wide table, a long code line or an unbroken email address then widens the column, the card around it, and the page. - **Let the page root shrink.** A single-column grid is one track as wide as its content. Write it as `grid-cols-[minmax(0,1fr)]`, which lets the track go below its content, or put `min-w-0` on the item. [App Page](https://www.youj.dev/docs/components/app-page) is already a container that shrinks; the sections you put in it are yours. - **Do the same on cards in a row.** A card in a flex or grid row needs `min-w-0` for its text to truncate or wrap instead of stretching the row. - **Let a wide table scroll inside itself.** [Table](https://www.youj.dev/docs/components/table) already scrolls sideways in its own frame, announced as a labeled region, so give it a parent that can shrink (the two rules above) and the rest of the page stays put. At 390px, keep the columns that identify the row (a name, a status) first, hide the rest with the Columns menu or `meta` on narrow screens, and set `size` on columns so they don't squeeze into unreadable slivers. ```tsx
{/* text that wraps */} {/* many columns */}
``` To check a layout, open the page at 390px wide and scroll sideways: only a table's own frame should move. ## The ready-made frame Create builds the same frame from copy-in parts, with a choice of sidebars, top bars, account parts and search, themed and exported as code. [Create](https://www.youj.dev/create) shows them together; each is also an item you can add by name. Each part is a separate `add`. Nothing pulls in a sidebar when you add a top bar, or search when you add either, so add the ones you use: ```bash npx @youj/cli add frame-shell frame-sidebar-classic frame-topbar-search frame-account-menu frame-search-palette ``` | Item | What it is | | --- | --- | | `frame-shell` | `AppFrame`, the layout all the others plug into: the sidebar slot, the top bar slot, the page, and the phone fallback. It owns the navigation and the current page, which every other part reads. Every frame item brings it along. | | `frame-sidebar-classic`, `-workspace`, `-rail-panel`, `-icon-rail`, `-glass` | The sidebars. Pick one. | | `frame-topbar-breadcrumb`, `-minimal`, `-search`, `-tabs` | The top bars. Pick one. | | `frame-account-card` | The account row at the foot of a sidebar. | | `frame-account-menu`, `frame-account-status` | The account in a top bar: the avatar menu, or one with a status picker. | | `frame-search-palette`, `frame-search-field` | Search for a top bar: the `⌘K` palette, or an inline field with suggestions. | | `frame-workspace`, `frame-upgrade-card`, `frame-notifications` | Optional extras for the sidebar's header and foot, and the top bar's actions. | The parts meet only in `AppFrame`, so any sidebar goes with any top bar. A sidebar takes a `header` and a `footer`, and a top bar takes `search`, `actions` and `account`: ```tsx title="components/app-shell.tsx" 'use client' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { Suspense, type ReactNode } from 'react' import { AppFrame, FrameLogo, navPages, type FrameNavSection } from '@/blocks/frame/shell' import { FrameAccountCard } from '@/blocks/frame/account-card' import { FrameAccountMenu } from '@/blocks/frame/account-menu' import { FrameSearchPalette } from '@/blocks/frame/search-palette' import { FrameSidebarClassic } from '@/blocks/frame/sidebar-classic' import { FrameTopbarSearch } from '@/blocks/frame/topbar-search' const user = { name: 'Ada Lovelace', email: 'ada@acme.com' } function Frame({ nav, children }: { nav: FrameNavSection[]; children: ReactNode }) { const pathname = usePathname() const router = useRouter() return ( page.item.href === pathname)?.item.id} linkComponent={Link} // Links route clicks; route the palette's and the search's picks here. onNavigate={(item, event) => !event && item.href && router.push(item.href)} sidebar={} footer={ router.push('/sign-in')} />} />} topbar={} account={ router.push('/sign-in')} />} />} > {children} ) } export function AppShell({ nav, children }: { nav: FrameNavSection[]; children: ReactNode }) { return ( {children} ) } ``` `nav` is sections of items with an `id`, a `label`, an `href` and an `icon`; the sidebars draw it, the top bars name the current page from it and the palette lists it. The `Suspense` is the same rule as above: `Frame` reads the URL. On phones, `mobileNav="tabs"` on `AppFrame` adds a bottom tab bar for the first pages. The icon-rail sidebar asks for it itself. Create wires the parts you choose together like this and exports the result, so start there if you'd rather pick than assemble. --- # Build a marketing site > Put a header, hero, features, pricing, testimonials, FAQ and footer together from the basic components. Source: https://www.youj.dev/docs/guides/build-a-marketing-site A marketing site is a stack of full-width bands, each with its content held to one column width. This guide builds one from basic components and plain Tailwind classes, section by section. Copy a section on its own, or all of them into one page. It assumes you ran `npx @youj/cli init`, which leaves the provider at its own roomy 100% size, the right one for marketing pages. The components are 13px text by default, so set your own text with the [type classes](https://www.youj.dev/docs/theming#text-and-type) or the [Typography](https://www.youj.dev/docs/components/typography) components, as below. ## The page frame Hold every section's content to one width with a container class, and give each section the same vertical padding. Alternating bands (`bg-band`) separate sections without borders. ```tsx title="components/marketing/section.tsx" import type { ComponentProps } from 'react' /** A full-width band; its content sits in a centered column. */ export function Section({ band, children, ...props }: ComponentProps<'section'> & { band?: boolean }) { return (
{children}
) } ``` The sections below use `Section`. Header and footer use the same `max-w-6xl` column so every edge lines up. ## The header A logo, links and a call to action. The links and the mobile menu are in [Navigation Menu](https://www.youj.dev/docs/components/navigation-menu#a-header-with-a-mobile-menu); this is the shape around them: ```tsx title="components/marketing/header.tsx" import Link from 'next/link' import { Button } from '@youj/react' export function Header() { return (
Acme {/* The navigation: see Navigation Menu */}
) } ``` ## The hero An eyebrow, a title, a lead paragraph and two buttons, centered. Stack the eyebrow and title with `flex-col gap-*`; the title opens its container flush and has no margin of its own. ```tsx title="components/marketing/hero.tsx" import Link from 'next/link' import { Button, Eyebrow, TypographyH1, TypographyLead } from '@youj/react' import { Section } from './section' export function Hero() { return (
New in 2.0 Ship pages people finish reading Acme turns your docs into a site in minutes, with the same components your product uses.
) } ``` ## A features grid Cards in a grid. The grid stretches them to one height; the footer is optional. ```tsx title="components/marketing/features.tsx" import { Card, CardDescription, CardHeader, CardTitle, TypographyH2 } from '@youj/react' import { Section } from './section' const features = [ { title: 'Fast by default', text: 'Static pages that load before anyone blinks.' }, { title: 'Made to be edited', text: 'Change copy and layout without a developer.' }, { title: 'Accessible', text: 'Keyboard and screen reader support in every part.' }, { title: 'Themed', text: 'Your colors and fonts, applied everywhere at once.' }, { title: 'Responsive', text: 'Sections reflow from phone to widescreen.' }, { title: 'Analytics-ready', text: 'Add the tool you already use.' }, ] export function Features() { return (
Everything a launch page needs
{features.map((feature) => ( {feature.title} {feature.text} ))}
) } ``` ## Pricing with a monthly and yearly toggle A [Segmented Control](https://www.youj.dev/docs/components/segmented-control) switches the period. The state makes this a Client Component. Give the recommended plan a ring (see [Card](https://www.youj.dev/docs/components/card#a-featured-card)) and keep the footers level with `mt-auto`. ```tsx title="components/marketing/pricing.tsx" 'use client' import { useState } from 'react' import Link from 'next/link' import { Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, SegmentedControl, SegmentedControlItem, TypographyH2, TypographyList, } from '@youj/react' import { Section } from './section' const plans = [ { name: 'Starter', monthly: 0, yearly: 0, features: ['One site', 'Community support'], featured: false }, { name: 'Pro', monthly: 24, yearly: 20, features: ['Unlimited sites', 'Custom domains', 'Email support'], featured: true }, { name: 'Team', monthly: 64, yearly: 52, features: ['Everything in Pro', 'Roles and approvals', 'Priority support'], featured: false }, ] export function Pricing() { const [period, setPeriod] = useState('yearly') return (
Simple pricing Monthly Yearly
{plans.map((plan) => ( {plan.name} ${period === 'yearly' ? plan.yearly : plan.monthly} per month{period === 'yearly' && plan.yearly > 0 ? ', billed yearly' : ''} {plan.featured && ( Most popular )} {plan.features.map((feature) => (
  • {feature}
  • ))}
    ))}
    ) } ``` ## Testimonials Quotes with the person beneath, in cards. ```tsx title="components/marketing/testimonials.tsx" import { Avatar, AvatarFallback, Card, CardContent, CardFooter, TypographyBlockquote, TypographyH2, TypographySmall, TypographyMuted } from '@youj/react' import { Section } from './section' const quotes = [ { quote: 'We launched in an afternoon.', name: 'Maya Chen', role: 'Founder, Loom & Co', initials: 'MC' }, { quote: 'The page finally looks like our product.', name: 'Jonas Weber', role: 'Head of Design, Northwind', initials: 'JW' }, { quote: 'Our marketing team edits it without asking us.', name: 'Priya Nair', role: 'CTO, Fieldnote', initials: 'PN' }, ] export function Testimonials() { return (
    Teams that shipped with Acme
    {quotes.map((q) => ( “{q.quote}” {q.initials}
    {q.name} {q.role}
    ))}
    ) } ``` ## An FAQ An [Accordion](https://www.youj.dev/docs/components/accordion) in a narrower column. Each trigger is an `h3`, so the section above it is an `h2`. ```tsx title="components/marketing/faq.tsx" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, TypographyH2 } from '@youj/react' import { Section } from './section' const questions = [ { q: 'Can I use my own domain?', a: 'Yes, on Pro and Team. Point your DNS at us and we handle the certificate.' }, { q: 'Can I cancel any time?', a: 'Yes. Your site stays up until the end of the period you paid for.' }, { q: 'Do you offer refunds?', a: 'Within 30 days of your first payment, no questions asked.' }, ] export function Faq() { return (
    Questions {questions.map((item) => ( {item.q} {item.a} ))}
    ) } ``` ## The footer Link columns and a closing line, in the same column width as the rest. ```tsx title="components/marketing/footer.tsx" import Link from 'next/link' import { Eyebrow, Separator, TypographyMuted } from '@youj/react' const columns = [ { title: 'Product', links: [['Features', '/features'], ['Pricing', '/pricing']] }, { title: 'Company', links: [['About', '/about'], ['Contact', '/contact']] }, { title: 'Legal', links: [['Privacy', '/privacy'], ['Terms', '/terms']] }, ] export function Footer() { return (

    Acme

    Pages for products worth launching.
    {columns.map((column) => ( ))}
    © 2026 Acme, Inc.
    ) } ``` ## Put it together ```tsx title="app/page.tsx" import { Faq } from '@/components/marketing/faq' import { Features } from '@/components/marketing/features' import { Footer } from '@/components/marketing/footer' import { Header } from '@/components/marketing/header' import { Hero } from '@/components/marketing/hero' import { Pricing } from '@/components/marketing/pricing' import { Testimonials } from '@/components/marketing/testimonials' export default function Home() { return ( <>
    ) } ``` Only the pricing section needs `'use client'`, for its state. If you put the navigation's `navigationMenuTriggerStyle()` in the header, the header becomes a Client Component too. --- # Light, dark and system in Next.js > Let people choose light, dark or the operating system's setting, with no flash on load. Source: https://www.youj.dev/docs/guides/light-dark-and-system Youj's palette follows `data-theme` on ``. This recipe gives an app a switch for it that remembers the choice, follows the operating system until someone picks, and paints the right theme on the first frame. It needs no library. ## Set it up Two pieces do the work: `ThemeScript`, a tiny script in `` that sets the theme before the page paints, and `ThemeToggle`, the switch. `npx @youj/cli init` writes the first in a Next.js app. ### Add the script to the layout Render `ThemeScript` in ``, and put `suppressHydrationWarning` on ``: the script sets an attribute the server couldn't know, and React would otherwise report it. Don't write `data-theme` yourself. ```tsx title="app/layout.tsx" import { basalt, YoujProvider, themeProps } from '@youj/core' import { ThemeScript } from '@youj/react' import './globals.css' export default function RootLayout({ children }: LayoutProps<'/'>) { return ( {children} ) } ``` ### Add the toggle A [ThemeToggle](https://www.youj.dev/docs/components/theme-toggle) with no props is complete. Use the `button` variant in a header, where one press flips light and dark, and `segmented` in settings, where Light, Dark and System are all offered: ```tsx title="components/header.tsx" import { ThemeToggle } from '@youj/react' export function Header() { return (
    ) } ``` That's all. The first visit follows the operating system. A choice is stored in `localStorage` under `theme`, shared by every toggle on the page and by other tabs, and applied by the script on the next load, before anything paints. ## From your own control To change the mode from something that isn't a ThemeToggle, such as a menu item or a shortcut, use `useThemeMode()` from `@youj/core`. It returns the choice (`mode`: `'light'`, `'dark'` or `'system'`), the scheme on screen (`scheme`: `'light'` or `'dark'`) and `setMode`. It reads and writes the same store as the toggle, so the two stay in step. `setThemeMode(mode)` does the same outside a component. ```tsx 'use client' import { useThemeMode } from '@youj/core' export function DarkModeItem() { const { scheme, setMode } = useThemeMode() return } ``` ## No flash in system or light mode The server can't know the operating system's scheme or the visitor's, so for `system` or `light` put `ModeScript` in `` with the same mode. It sets the visitor's stored choice, or else that mode, as `data-theme` and `color-scheme` on `` before the page paints, and the provider takes over after hydration. For `dark`, the default, with no toggle, you don't need it. ```tsx title="app/layout.tsx" import { ModeScript, YoujProvider } from '@youj/core' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` `ModeScript` takes a `nonce` for a strict Content-Security-Policy. `` needs `suppressHydrationWarning` because the script writes `data-theme` before React hydrates. ## Reading the mode `useMode()` returns the mode in effect and the scheme it draws: ```tsx import { useMode } from '@youj/core' function SchemeNote() { const { mode, scheme } = useMode() // 'system' | 'light' | 'dark', and 'light' | 'dark' return

    {mode === 'system' ? `Following your system: ${scheme}` : `Fixed to ${scheme}`}

    } ``` `mode` is `dark` when no provider sets one. `scheme` is that mode resolved: with `system` it is the operating system's, and on the server and during hydration it is `dark`, the palette's default, then corrects on mount. With no mode set it reads `data-theme` on ``, so it follows `next-themes` too. Use it for what a component shows or does, and the `dark:` variant for styling. ThemeScript, from the ThemeToggle page, is the other half: it restores a choice the visitor made and stored. `ModeScript` applies a choice the app made. ## If you use next-themes Keep next-themes as the owner of `data-theme` and leave `ThemeScript` out; it brings its own script. [Installation](https://www.youj.dev/docs/installation#alternative-next-themes) has the layout, and the toggle takes next-themes' `theme` and `setTheme`. ## Without Next.js In a single-page app, put the script inline in `index.html`; [Installation](https://www.youj.dev/docs/installation#manual) has it. The toggle needs nothing else. --- # Composing components > Build your own components with shared motion, icons and accessible controls. Source: https://www.youj.dev/docs/guides/composing-components Build your own components from Youj pieces to share the library’s timing. This guide creates a plan picker with sliding radio selections, prices that animate between billing periods and a button label that follows the chosen plan. ```tsx 'use client' import { useId, useState, type KeyboardEvent } from 'react' import { motion } from 'motion/react' import { Counter, MorphLabel, SlidingIndicator, useMorphRoot } from '@youj/motion' import { ArrowRightIcon, CheckIcon } from '@youj/icons' const PLANS = [ { id: 'hobby', name: 'Hobby', monthly: 0, yearly: 0, features: ['1 project', 'Preview deployments', 'Community support'] }, { id: 'pro', name: 'Pro', monthly: 24, yearly: 19, features: ['Unlimited projects', 'Password protection', 'Email support'] }, { id: 'team', name: 'Team', monthly: 48, yearly: 38, features: ['SAML single sign-on', 'Audit logs', 'Priority support'] }, ] as const const PERIODS = ['monthly', 'yearly'] as const const usd = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } as const const formatUsd = new Intl.NumberFormat('en-US', usd).format /** Arrow keys, Home and End move the selection within a radio group, and focus follows it. */ function radioKeys(options: readonly T[], current: T, select: (next: T) => void) { return (event: KeyboardEvent) => { const forward = getComputedStyle(event.currentTarget).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight' const index = options.indexOf(current) const last = options.length - 1 let next: number if (event.key === 'Home') next = 0 else if (event.key === 'End') next = last else if (event.key === 'ArrowDown' || event.key === forward) next = index === last ? 0 : index + 1 else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') next = index === 0 ? last : index - 1 else return event.preventDefault() select(options[next]!) event.currentTarget.querySelector(`[data-value="${options[next]}"]`)?.focus() } } export default function PlanPicker() { const [period, setPeriod] = useState<(typeof PERIODS)[number]>('yearly') const [planId, setPlanId] = useState<(typeof PLANS)[number]['id']>('pro') const plan = PLANS.find((p) => p.id === planId)! const morph = useMorphRoot(8) const id = useId() return (

    Choose a plan

    {PERIODS.map((p) => ( ))}
    p.id), planId, setPlanId)} className="relative isolate grid gap-3 @lg:grid-cols-3" > {/* The selection ring glides between cards. It sits behind them, so only its 2px ring shows. */} {PLANS.map((p) => { const price = period === 'yearly' ? p.yearly : p.monthly return ( ) })}

    {plan.name}, billed {period}

    Continue with {plan.name}
    ) } ``` Use the arrow keys in either group, switch the period a few times in quick succession, and try the preview at ¼ speed and with Reduced motion on. Every behavior you see comes from a Youj piece. The component itself only holds state. ## The approach 1. **Semantics first.** Build the component so it works with no motion at all: the right roles, names, states and keyboard behavior. 2. **Let pieces own the movement.** A component or motion piece that already solves a motion problem is interruptible, scaled by Speed and designed for reduced motion. Hand-rolled motion usually isn't. 3. **Timing only from tokens.** If you still need a transition of your own, take it from `useYouj()` or the `yj-duration-*` utilities. See [Motion in your own components](https://www.youj.dev/docs/motion/in-your-components). 4. **Check the three states.** Normal speed, ¼ speed (hard-coded timing shows up at once) and reduced motion. ## Build it ### Start with semantics A plan picker is two single-choice groups, so both are `radiogroup`s of `radio`s. Only the checked radio is in the tab order (a roving tab stop), and the arrow keys move the selection and the focus together, reversed under right-to-left text. No package ships that key handling, so the guide writes a small helper: ```tsx import { useState, type KeyboardEvent } from 'react' const PERIODS = ['monthly', 'yearly'] as const /** Arrow keys, Home and End move the selection within a radio group, and focus follows it. */ function radioKeys(options: readonly T[], current: T, select: (next: T) => void) { return (event: KeyboardEvent) => { const forward = getComputedStyle(event.currentTarget).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight' const index = options.indexOf(current) const last = options.length - 1 let next: number if (event.key === 'Home') next = 0 else if (event.key === 'End') next = last else if (event.key === 'ArrowDown' || event.key === forward) next = index === last ? 0 : index + 1 else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') next = index === 0 ? last : index - 1 else return event.preventDefault() select(options[next]!) event.currentTarget.querySelector(`[data-value="${options[next]}"]`)?.focus() } } ``` The group calls it with its options, the current value and the setter. Each radio carries `data-value` so the helper can move focus to it: ```tsx const [period, setPeriod] = useState<(typeof PERIODS)[number]>('yearly')
    {PERIODS.map((p) => ( ))}
    ``` Plan cards hold a lot of text, so each gets a short `aria-label` ("Pro, $19 per seat per month") and points `aria-describedby` at its feature list. A screen reader announces the name, the checked state and the position, then the features. ### Glide the selection Drop a [Sliding Indicator](https://www.youj.dev/docs/motion/sliding-indicator) into each group. It finds the selected item from the `aria-checked` the radios already carry, so there's nothing to wire. The container needs `relative isolate`, because the indicator sits behind the items. ```tsx import { SlidingIndicator } from '@youj/motion'
    {/* radios */}
    ``` The indicator is a plain element you restyle with `className`. The period switch uses it as a filled pill. The plan cards use the same block as a 2px accent ring, with a transparent fill. It sits behind each card, so only the ring shows around the selected one: ```tsx ``` It glides on the `smooth` spring, keeps its corners round mid-flight, and retargets from wherever it is when the selection changes again. ### Count the prices Each price is a [Counter](https://www.youj.dev/docs/motion/counter). Passing the current price as `from` means the first render shows the number instead of counting up to it, and `inView={false}` lets it animate as soon as the value changes. Switch the period mid-count and it retargets without a jump. ```tsx import { Counter } from '@youj/motion' ``` The numerals are tabular, so the cards don't reflow while digits change, and the formatted value is always in the document for assistive technology. ### Morph the call to action The button's label follows the selected plan. [Morph Label](https://www.youj.dev/docs/motion/morph-label) crossfades the old label out and the new one in, and `useMorphRoot` morphs the button's width on the same spring. Give the button `overflow-hidden` so the outgoing label doesn't spill while it shrinks. ```tsx import { m } from 'motion/react' import { MotionFeatures } from '@youj/core' import { MorphLabel, useMorphRoot } from '@youj/motion' import { ArrowRightIcon } from '@youj/icons' function ContinueButton({ plan }: { plan: { id: string; name: string } }) { const morph = useMorphRoot(8) // the button's corner radius, kept round while it morphs return ( Continue with {plan.name} ) } ``` The button is an `m` element inside `MotionFeatures`, so it also works in an app on ``. `features="max"` loads layout animation, which the width morph needs. The outgoing label is hidden from assistive technology as soon as it starts to leave, so the button's name is only ever the current one. The arrow is a Youj icon: the button is its trigger, so it plays when the button is hovered or focused. ### Check the states Nothing in the component names a duration, so the checks pass without extra code: - **¼ speed.** The glide, the count and the morph all slow down together. A transition that keeps its speed is hard-coded. - **Reduced motion.** The rings jump, the prices swap, and the label changes in place. The same information arrives, only without the travel. - **Interruption.** Arrow through the plans quickly, or toggle the period twice mid-count. Every piece continues from where it is, with no jump back to its start. ## Which piece to reach for | You need | Reach for | | --- | --- | | A selection that glides between items | [Sliding Indicator](https://www.youj.dev/docs/motion/sliding-indicator) | | A label or icon that changes in place | [Morph Label](https://www.youj.dev/docs/motion/morph-label) with `useMorphRoot` | | A number that changes | [Counter](https://www.youj.dev/docs/motion/counter) | | A region that opens and closes | [Collapse](https://www.youj.dev/docs/motion/collapse), or `yj-collapse` on a Base UI panel | | A skeleton that becomes content | [Content Swap](https://www.youj.dev/docs/motion/content-swap) | | Items that arrive, leave and reorder | [Presence List](https://www.youj.dev/docs/motion/presence-list) | | Pending, success and error on a control | [Async Feedback](https://www.youj.dev/docs/components/async-feedback), or [Async Button](https://www.youj.dev/docs/components/button#async-actions) | | Content arriving on screen | [Reveal](https://www.youj.dev/docs/motion/reveal), [Stagger](https://www.youj.dev/docs/motion/stagger), [TextSplit](https://www.youj.dev/docs/motion/text-split) | | Popovers, dialogs, sheets and toasts | [Overlay presets](https://www.youj.dev/docs/motion/presets) | | Icons that answer an interaction | [Icons](https://www.youj.dev/docs/icons), [IconSwap](https://www.youj.dev/docs/icons/icon-swap) | ## Accessibility checklist - Every control has a role, a name and a state before any motion is added. Motion never carries meaning on its own. - Keyboard focus is visible and sits outside decorative layers. Here the focus outline is offset past the selection ring. - Decorative layers are `aria-hidden`. The indicator and the morphing label's outgoing copy already are. - Changing values are announced once, at their final value. Counter keeps the formatted number in the document for that reason. - Arrow keys follow the reading direction, so the component works under `dir="rtl"`. ## Related - [Motion in your own components](https://www.youj.dev/docs/motion/in-your-components): useYouj, the presets, and reduced motion in your own code. - [Icons in your own components](https://www.youj.dev/docs/icons/in-your-components): Triggers, sizing and labels for Youj icons. - [Coming from shadcn/ui](https://www.youj.dev/docs/guides/shadcn): Bring the tokens and the motion into an existing shadcn app. --- # Styling hooks > The data attributes and CSS variables you can target from your own CSS, which ones are Youj's, and which are private. Source: https://www.youj.dev/docs/guides/styling-hooks Write selectors and overrides against the names on this page. Anything else, in particular a CSS variable that starts with `--ui--`, is private and can change in any release. A rename of a name on this page is a breaking change. ## One prefix rule Youj's own attributes all start with `data-yj-`. Attributes from Base UI, shadcn/ui and next-themes keep their own names and are not prefixed. | Attribute | Set by | Values | Meaning | | --- | --- | --- | --- | | `data-yj-theme` | `YoujProvider` with a `theme` | the theme id | Marks a subtree whose derived tokens are recomputed. | | `data-yj-look` | `YoujProvider` | `bezel`, `classic` | The look of the components. | | `data-yj-motion` | `YoujProvider` with a theme | `precise`, `calm`, `lively` | The theme's motion personality. | | `data-yj-scope` | `YoujProvider`, `ThemeScope`, `useYoujScope()` | empty | Marks an element that carries Speed (`--yj-time-scale`) and reduced motion (`--yj-motion`); the duration tokens resolve again on it. | | `data-yj-reduced` | `YoujProvider` with `reducedMotion="always"` | empty | Forces reduced motion in the subtree, whatever the OS says. | | `data-yj-scaling` | `YoujProvider` with `scaling` | `75` to `120` | The interface scale, in percent. | | `data-yj-density` | `YoujProvider` with `density` | `roomy`, `compact` | The layout room. | | `data-yj-device` | `YoujProvider` | `touch`, `pointer` | The input device the components size for. | | `data-yj-tone` | You, on a surface | `chrome`, `overlay`, `inverse` | Re-derives the color roles for that surface. | | `data-yj-trigger` | You, on a parent | empty | Plays the icons inside it on hover and keyboard focus. | The page color mode is `data-theme`, with `light` or `dark`. It keeps that name because next-themes sets it. The preset is `data-yj-theme`. ## Standard attributes These come from Base UI and shadcn/ui, and the components pass them through unchanged: `data-slot` (every component part), `data-state`, `data-open`, `data-closed`, `data-checked`, `data-unchecked`, `data-disabled`, `data-highlighted`, `data-selected`, `data-pressed`, `data-orientation`, `data-side`, `data-align`, `data-starting-style`, `data-ending-style`, `data-popup-open`, `data-variant`, `data-size`, `data-tone`, `data-status` and `data-loading`. ## CSS variables Variables that you may read or set: - The color roles, as `--color-` (`--color-canvas`, `--color-fg`, `--color-accent`…). - The theme contract, as `--yj-*`. - The shadow scale `--ui-shadow-xs`, `--ui-shadow-sm`, `--ui-shadow-md` and `--ui-shadow-lg`, the skeleton `--ui-shimmer` color, and the scrollbar thumb colors `--ui-scrollbar-thumb`, `--ui-scrollbar-thumb-hover` and `--ui-scrollbar-thumb-active`. Variables that start with `--ui--` (the panel padding, the press depth, the compact switch…) are internal to the components. Don't read or set them. `pnpm check:api` records both lists in `api/youj-surface.md`, so a change to a public name shows up in review. ## Canvas, WebGL and Lottie Code that draws with token colors resolves them once, so it has to hear about theme changes. Youj's backgrounds and players already do. For your own canvas code, `observeThemeChange(element, callback)` calls back whenever the theme above `element` changes: a light/dark switch, a preset, or a changed theme on a scope. ```ts import { observeThemeChange } from '@youj/core' // Tokens are formulas, so let the browser resolve one: read the color back // from an element inside the same scope that uses it. const probe = document.createElement('span') probe.hidden = true probe.style.color = 'var(--color-accent)' canvas.after(probe) const paint = () => redraw(getComputedStyle(probe).color) paint() const stop = observeThemeChange(canvas, paint) // call stop() on teardown ``` --- # Translating built-in text > Translate built-in labels, announcements and placeholders with language packs or your own text. Source: https://www.youj.dev/docs/guides/i18n Translate built-in text through `YoujProvider`, including close buttons, pagination labels, placeholders and screen-reader announcements. Each string is a key in `YoujLabels`. Pass only the keys you want to replace; the rest stay in English. ## Use a ready-made language Youj includes ready-made language packs. Import one and pass it to the provider: ```tsx title="app/layout.tsx" import { YoujProvider } from '@youj/core' import { de } from '@youj/core/locales/de' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` | Language | Import | Locale | |---|---|---| | Arabic (right to left) | `import { ar } from '@youj/core/locales/ar'` | `ar` | | Chinese, Simplified | `import { zhCN } from '@youj/core/locales/zh-CN'` | `zh-CN` | | Dutch | `import { nl } from '@youj/core/locales/nl'` | `nl-NL` | | French | `import { fr } from '@youj/core/locales/fr'` | `fr-FR` | | German | `import { de } from '@youj/core/locales/de'` | `de-DE` | | Hebrew (right to left) | `import { he } from '@youj/core/locales/he'` | `he-IL` | | Italian | `import { it } from '@youj/core/locales/it'` | `it-IT` | | Japanese | `import { ja } from '@youj/core/locales/ja'` | `ja-JP` | | Korean | `import { ko } from '@youj/core/locales/ko'` | `ko-KR` | | Polish | `import { pl } from '@youj/core/locales/pl'` | `pl-PL` | | Portuguese (Brazil) | `import { ptBR } from '@youj/core/locales/pt-BR'` | `pt-BR` | | Spanish | `import { es } from '@youj/core/locales/es'` | `es-ES` | | Swedish | `import { sv } from '@youj/core/locales/sv'` | `sv-SE` | | Turkish | `import { tr } from '@youj/core/locales/tr'` | `tr-TR` | - **Only what you import ships.** Each language is its own module, so an app that uses German loads German and nothing else. With `@youj/core` alone, import from `@youj/core/locales/`. With source copied in, `npx @youj/cli add locale de` (the item `youj-locale-de`) copies the pack in. - **Copied in, English only by default.** An app that copies the core gets English, and no language pack until it adds one: `npx @youj/cli add locale de` copies the German pack to `locales/de.ts` (`import { de } from '@/locales/de'`). The codes are `ar`, `de`, `es`, `fr`, `he`, `it`, `ja`, `ko`, `nl`, `pl`, `pt-br`, `sv`, `tr` and `zh-cn`; the item behind each is `youj-locale-`. A language pack holds every key and the copied dictionary type is open, so a pack works with whichever components are present: keys of components you did not copy are inert. Each copied component brings only its own English strings, as the item `youj-labels-` (a small file in `lib/youj/labels/`). `useLabels()` and `defaultLabels` are the item `youj-labels`, which components that read the whole dictionary add themselves. - **Plurals follow the language.** Text with a count picks its word form with `Intl.PluralRules`, so Polish says "1 plik", "2 pliki", "5 plików", and Arabic has its dual and its few and many forms. Numbers are formatted with `Intl.NumberFormat` ("1.234 Dateien"). - **Right to left.** For Arabic and Hebrew, also set `dir="rtl"` on `` and on the provider (``), or on a `ThemeScope` around the part of the page in that language. Layout, arrow keys, overlays and motion then mirror. See [Right-to-left layouts](https://www.youj.dev/docs/guides/rtl). - **Native review welcome.** The packs were translated for Youj. If a string reads wrong in your language, [CONTRIBUTING.md](https://github.com/youj-dev/ui/blob/main/CONTRIBUTING.md#improving-a-language) explains how to fix it. ### Change a few strings A pack is a plain object, so spread it and set the keys you want to read differently: ```tsx ``` ## Your own translations Every key is optional. Pass only the ones you have, and the rest stay English, so you can translate a screen at a time: ```tsx ``` `locale` and `labels` are separate on purpose: `locale` formats numbers and dates with `Intl`, and `labels` holds the words around them. ## The keys `YoujLabels` is exported from `@youj/core`, and `defaultLabels` holds the English. Your editor completes every key and shows its type. The keys are flat, grouped by the component that uses them: | Group | Examples | |---|---| | Shared | `close`, `dismiss`, `cancel`, `save`, `retry`, `clear`, `search`, `searchPlaceholder`, `selectPlaceholder`, `loading`, `loadingEllipsis`, `remove(name)` | | Async feedback | `working`, `done`, `failed` (AsyncButton, AsyncFeedback, toasts) | | Navigation | `pagination`, `previous`, `next`, `breadcrumb`, `showMoreCount(n)`, `toggleSidebar`, `menu`, `onThisPage` | | Carousel | `carousel`, `slide`, `previousSlide`, `nextSlide`, `slideOf(n, total)`, `slideNumber(n)` | | Command palette | `commandPalette`, `commandPalettePlaceholder`, `home`, `navigate`, `open`, `escKey` | | Inputs and pickers | `chooseDate`, `startDate`, `endDate`, `showPassword`, `increase`, `decrease`, … | | Data table | `rowsPerPage`, `columns`, `clearFilters`, `sortAscending`, `firstPage`, `lastPage`, `pageOf(page, total)`, … | | Chat, kanban, uploads, notifications | the composer, message actions, lanes, file rows, "Mark all as read", … | One key serves every component where the text means the same thing: `close` names the Dialog, Sheet and Toast close buttons alike. Text with the same English but a different job has its own key. ## Text with values in it Text that contains a number or a name is a function, so each language can put the value where its grammar wants it and handle its own plurals: ```tsx `Folie ${n} von ${total}`, showMoreCount: (count) => `${count} weitere anzeigen`, remove: (name) => `${name} entfernen`, }} > ``` Numbers reach these functions already formatted where the component formats them (a data table's counts, for instance, arrive as strings in the provider's `locale`). ## Translating blocks Blocks are copied into your app, so they carry their own text. Of the blocks, only those that reuse a built-in component's strings (a data table's pagination, a command palette's hints) follow `labels`. The rest keep their words in an English `copy` object, so a German app gets German components and English blocks until you translate each block's `copy`. Every block takes a `copy` prop. It is partial: pass the strings you want to replace and the rest stay English. Strings with values in them are functions, as above: ```tsx `${name} auswählen`, movedMany: (count) => `${count} Dateien verschoben`, }} /> ``` - **Dates, numbers and sizes** follow the app's `locale` (``), or the block's `locale` prop, without any `copy`. Sizes read "18,2 MB" in German and "18.2 MB" in English. - **Plurals are yours to write.** A block's English picks "1 file" or "2 files" with a plain comparison. In `copy`, write the word forms your language needs, with `Intl.PluralRules` as the language packs do: `new Intl.PluralRules('pl').select(count)` returns `one`, `few` or `many`. - **Read the block's `copy` type** for the keys. Your editor completes them, and the block's `DEFAULT_COPY` shows the English each one replaces. - **Shared words go through `labels`.** The Esc key cap on shortcut hints is the `escKey` label, so a language pack that writes it differently (French "Échap") changes it everywhere at once. ## A component's own prop still wins Components that had a prop for their text keep it. The prop wins, then the provider's label, then English: ```tsx // "Wird geladen" from the provider… // …unless this button says something more specific. Speichern ``` ## Nesting Providers nest the way `locale` does: an inner provider's keys win and everything else comes from the one above it. `ThemeScope` keeps the labels of the provider around it. ```tsx {/* A French help widget inside a German app. */} ``` ## In your own components `useLabels()` returns the labels in effect: English without a provider, merged with every provider above otherwise. Use it for text that should follow the app's language, and add a prop when a caller may need to override it. ```tsx import { Button } from '@youj/react' import { useLabels } from '@youj/core' import { XIcon } from '@youj/icons' function ClearButton({ label, onClear }: { label?: string; onClear: () => void }) { const labels = useLabels() return ( ) } ``` --- # Right-to-left layouts > Lay out Arabic, Hebrew and Persian apps right to left: sides, arrow keys, overlays and motion mirror from one dir prop. Source: https://www.youj.dev/docs/guides/rtl Set the reading direction once, with `dir` on `YoujProvider`, and every component follows it. Spacing, borders and corners use logical sides, so they mirror in CSS. Arrow keys, menu and popover placement, sliders, sheets, carousels and directional motion read the same direction in JavaScript, so they mirror too. ## Turn it on Set `dir` on `` and on the provider. The `` attribute covers the page and your own CSS. The provider's `dir` reaches the components, Base UI and everything they portal. ```tsx title="app/layout.tsx" import { YoujProvider } from '@youj/core' import { ar } from '@youj/core/locales/ar' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` - **Without `dir` on the provider,** components follow `` in the browser and update when it changes. A server can't read the document, so the server render and the hydration render use `ltr` until then. Pass `dir` on the provider for a server-rendered RTL app, so the first paint is already mirrored. - **`dir="auto"`** takes the direction from the first strong character of the content, the way the HTML attribute does. Use it for a region whose language you don't know in advance, such as user-written content. - **The language pack is separate.** `dir` lays the page out right to left and `labels` translates the built-in text. An Arabic app usually sets both. See [Translating built-in text](https://www.youj.dev/docs/guides/i18n). ## One part of the page `ThemeScope` takes `dir` too, so you can mirror part of a page. The overlays that part opens (menus, popovers, dialogs and sheets) are mirrored as well. ```tsx import { ThemeScope } from '@youj/core' ``` ## In your own components `useDirection()` returns the direction in effect: `'ltr'` or `'rtl'`. Use it for anything CSS can't express, such as the sign of a horizontal offset, the key that means "next", or the side a panel opens on. ```tsx import { useDirection } from '@youj/core' function Pager({ onNext, onPrevious }: { onNext: () => void; onPrevious: () => void }) { const rtl = useDirection() === 'rtl' return (
    { if (event.key === (rtl ? 'ArrowLeft' : 'ArrowRight')) onNext() if (event.key === (rtl ? 'ArrowRight' : 'ArrowLeft')) onPrevious() }} /> ) } ``` For styling, use logical utilities rather than reading the direction: `ms-2` instead of `ml-2`, `ps-4` instead of `pl-4`, `start-0` instead of `left-0`, `text-start`, `rounded-s-md` and `border-e`. To mirror an icon, add `rtl:-scale-x-100`. If you use Base UI primitives directly under the provider, wrap them in Base UI's `DirectionProvider` with `direction={useDirection()}`. Youj's own components already do this. ## What mirrors | Area | Under RTL | |---|---| | Layout | Padding, margins, borders, corners, insets and text alignment, through logical properties. | | Keyboard | Left and right arrows in Tabs, Radio Group, Toggle Group, Toolbar, Menubar, Slider, Carousel, Rating, Tree View, Kanban, Data Table and Data Grid follow what the reader sees. ArrowLeft goes to the next tab. | | Overlays | Submenus open on the left. Placements set to `inline-start` or `inline-end` flip, and every portal carries `dir`. | | Sheets and drawers | `side="start"` / `side="end"` on Sheet and `swipeDirection="start"` / `"end"` on Drawer follow the direction. `left` and `right` stay physical. | | Icons | Chevrons, arrows, back and forward, send, reply, undo and redo, and log in and log out point the other way. | | Motion | Tab and segmented indicators, Marquee, PageTransition's `slide` and `curtain`, IconSwap's slide and wipe, icon nudges and the toast's swipe all travel in the reading direction. | ## What doesn't mirror Some things keep their direction in every script, because their meaning is physical or universal: - **Media controls.** Play, fast forward, skip and a video's scrubber keep their direction, as they do on every RTL platform. - **Clocks and dials.** Gauges, rings and the Dial knob turn clockwise. - **Timelines on a time axis.** The Track Timeline and Waveform Scrubber run left to right, as audio and video editors do. - **Numbers, codes and maps.** Phone numbers, amounts, IDs and code stay left to right inside RTL text (components isolate them with `dir="ltr"`), and maps keep their geography. - **Symmetric and two-way glyphs.** Swap arrows, expand and collapse chevrons pointing up or down, and alignment icons are unchanged. ## Testing - **Storybook:** the toolbar's Direction control renders every story as an Arabic app: `dir="rtl"` on the document and on the provider, the Arabic locale and labels. - **Docs previews:** the RTL button in a preview's toolbar mirrors that example's stage. - **Unit tests:** render under `` and press the keys a reader would. ArrowLeft moves to the next tab, and the submenu opens with ArrowLeft. - **Lint:** in this repository, `pnpm -F @youj/react lint:direction` (part of `verify`) rejects physical `left`/`right` utilities and unmirrored directional icons in component and block sources. When a side really is physical, such as map coordinates, mark the line with `// youj-allow: physical side `. --- # Touch mode > Components present and behave for how they are operated: a popover under a mouse, a bottom sheet under a thumb. Source: https://www.youj.dev/docs/guides/touch-mode Youj does not shrink a desktop interface to fit a phone. A component changes how it is presented and operated depending on the input. - Under a **pointer**, a picker is a popover that grows out of its trigger. - Under a **thumb**, the same picker is a bottom sheet with a grab handle and 48px rows. Controls are 44px or taller (a small control keeps its look and takes an invisible 44px hit area), text fields are 16px so iOS does not zoom, and nothing depends on hover. This is separate from responsive layout. A layout follows the width of the window. Touch mode follows how the page is operated, so a wide tablet is touch and a narrow desktop window is not. ## What changes on touch | Component | Pointer | Touch | |---|---|---| | [Dropdown Menu](https://www.youj.dev/docs/components/dropdown-menu), [Context Menu](https://www.youj.dev/docs/components/context-menu) | Popover; right-click | Action sheet; long press | | [Select](https://www.youj.dev/docs/components/select) | Popover over the trigger | Bottom sheet | | [Combobox](https://www.youj.dev/docs/components/combobox), [Multi-select](https://www.youj.dev/docs/components/multi-select) | Popover with a search field | Sheet with the search field on top, above the keyboard | | [Command](https://www.youj.dev/docs/components/command), [Command Palette](https://www.youj.dev/docs/components/command-palette) | Centered dialog | Tall sheet | | [Popover](https://www.youj.dev/docs/components/popover), [Date Picker](https://www.youj.dev/docs/components/date-picker), [Date Range Picker](https://www.youj.dev/docs/components/date-range-picker), [Color Picker](https://www.youj.dev/docs/components/color-picker) | Popover | Sheet; 44px day cells and swatches | | [Dialog](https://www.youj.dev/docs/components/dialog) | Centered window | Bottom sheet that is pulled down to dismiss, above the keyboard | | [Alert Dialog](https://www.youj.dev/docs/components/alert-dialog) | Centered window | Stays centered and is not draggable; actions stack at 44px | | [Sheet](https://www.youj.dev/docs/components/sheet) | Panel on an edge | Full width up to 28rem, with a 44px close button | | [Tooltip](https://www.youj.dev/docs/components/tooltip) | Hover and focus | Does not open by default; opt in to a long press with `longPress` | | [Hover Card](https://www.youj.dev/docs/components/hover-card) | Hover and focus | A tap opens it; a second tap follows the link | | Controls ([Button](https://www.youj.dev/docs/components/button), [Input](https://www.youj.dev/docs/components/input), Tabs, Toggle) | 24 to 40px | 8px taller, default 44px, small sizes with a 44px hit area; inputs at 16px | | Keyboard shortcut hints | Shown | Hidden | Each component's page has a Touch section with the details. [Sheet Presentation](https://www.youj.dev/docs/components/presentation) covers building your own. ## Setting the device The default is `auto`: the primary pointer decides (`(pointer: coarse)` is touch). Force one for a subtree, and for everything it portals, with `device` on `YoujProvider` or `ThemeScope`. ```tsx import { YoujProvider, ThemeScope } from '@youj/core' {/* follows the hardware */} {/* sheets and touch sizes, even on a desktop */} ``` Set it once, near the root of what it governs. A scope nested inside a forced one can force the other way for its own parts, but it cannot hand the choice back to `auto`. `portalContainer` on the provider sends the overlays of a subtree to an element you choose instead of the end of ``. The docs use it to show a phone's sheets inside a phone-sized frame on a desktop (see below). ## In your own components ```tsx import { useDevice, useDeviceSetting, usePortalContainer } from '@youj/core' const device = useDevice() // 'touch' | 'pointer' const setting = useDeviceSetting() // 'auto' | 'touch' | 'pointer' ``` `useDevice` says what a component should do when it opens. The server and the hydration render say `pointer` and the client corrects on mount, so use it for behavior at open time, not for markup that is on the page at load. For styling use the `touch:` and `fine:` variants, which follow the hardware or a forced device (use these rather than Tailwind's `pointer-coarse:`, which cannot see a forced one). `@youj/core` also exports the building blocks the provider uses: `DEVICE_SETTINGS`, `DEFAULT_DEVICE_SETTING`, `isDeviceSetting` and `resolveDevice(setting, coarse)`. ## Previewing touch on a desktop Every demo in these docs has a Pointer and Touch control. On a desktop, Touch shows the demo in a phone-sized screen, where menus, selects and dialogs rise as sheets from the bottom of that screen, and a mouse drag works as a finger would. The choice is remembered, and it defaults to your hardware. In Storybook, the toolbar's Device control does the same for every story. ## What is not done - A sheet has one height. Half and full detents are not built. - A submenu opens as another sheet above its parent. Native menus push inside one sheet. - There is no haptic feedback. The web has no equivalent on iOS. - On an iPad, native menus are popovers. Pass `presentation="popover"` to keep one. - Real keyboards cannot be emulated, so the keyboard handling is verified against a simulated visual viewport. --- # Server rendering > Using Youj with React Server Components and server rendering: what runs where, what the server and the first browser render agree on, and what changes after hydration. Source: https://www.youj.dev/docs/guides/ssr Youj renders on the server. The packages mark their client components with `"use client"`, so a Server Component imports them directly, with nothing to transpile, and the page arrives as HTML. ## What runs where - **Import Youj components from a Server Component.** `import { Card } from '@youj/react'` works in a file with no directive. - **Functions come from a Client Component.** A prop that's a function (`onAction`, `onValueChange`, `render`) can't cross from a Server Component. Put the component that passes it in a file that starts with `'use client'`. - **A sound pack is such a function.** `sound={beacon}` goes on a `YoujProvider` in a client file; `npx @youj/cli add sound` writes `app/providers.tsx` for this on the App Router. - **Hooks are client-side.** `useYouj`, `useDevice` and the rest read the provider, so use them in a Client Component. - **What reads the clock goes inside Suspense under `cacheComponents`.** create-next-app turns Next.js's `cacheComponents` on, and then `next build` stops when a Client Component reads the current time while its page prerenders. The Date Range Picker does (its presets count from today), and so do blocks and examples that count from now (a countdown, an upload's progress, a schedule's current hour). Wrap them, or the part of the page around them, in `` and they render per request. `npx @youj/cli create` wraps its screens for you. ## The theme, without a flash Spread the theme onto `` in your root layout: ```tsx import { basalt, themeProps } from '@youj/core' ``` `themeProps` returns the attributes that apply a theme to an element: its contract as CSS variables, the preset's id and its motion personality. The server's HTML already carries the theme, so the first paint is themed. The light or dark scheme is set on `` by a script before React hydrates, which is why `` needs `suppressHydrationWarning`. `npx @youj/cli init` writes both in a Next.js or React Router app. See [Installation](https://www.youj.dev/docs/installation#theme) for the setup by hand. ## What the first render assumes The server and the browser's first render must agree, so some things start from a fixed value and correct themselves right after hydration: | What | On the server and the first render | After mount | | --- | --- | --- | | Device (`useDevice`) | `pointer` | `touch` if the primary pointer is coarse | | Reduced motion | The OS setting is off, unless you set `reducedMotion="always"` on the provider | Follows the OS setting | | Direction | The provider's or ``'s `dir`, else `ltr` | `auto` is read from the element | | Quality (`quality="auto"`) | The default tier | Measured on the device | | Numbers, dates | The provider's `locale`, `en-US` by default | The same | | "5 min ago", local clock times | UTC | The browser's time zone | What this means for your code: - **Style with the `touch:` variant, not with `useDevice`.** CSS applies before hydration. A device read in JS is for what a component does when it opens (a sheet or a popover), not for markup that's on the page at load. - **Set what you know on the server.** Pass the user's `locale` and `dir` to `YoujProvider` from the request, and `reducedMotion="always"` if you want it on from the first render. - **Expect a relative time to change once.** If your own markup shows one, render it the same way. ## Motion Motion's engine loads on demand, after the page: components render their resting state on the server and until the chunk lands, then animate. See [Motion loads on demand](https://www.youj.dev/docs/installation#motion-loads-on-demand). --- # Accessibility > Understand Youj’s accessibility support, automated checks and what you need to test in your app. Source: https://www.youj.dev/docs/guides/accessibility Youj components build on Base UI’s roles, states and keyboard support. Each component page explains its keyboard controls, focus behavior and screen-reader announcements. Modal overlays trap focus and return it when closed. Live regions announce changes such as busy states, toasts and sorting. You still need to check your app’s copy, heading order, alt text, icon-only button names and task flows. A component library cannot verify these choices for you. ## Contrast The theme derivation keeps every text color at 4.5:1 and every field border at 3:1 on every surface, in light and dark, for any theme you define. `checkTheme` reports a pair that misses AA as an error, and the provider, [Create](https://www.youj.dev/create), the CLI and the MCP server refuse a theme that misses it unless you opt out. See [Theming › Guardrails](https://www.youj.dev/docs/theming#guardrails). ## Target size WCAG 2.2's 2.5.8 asks for pointer targets of at least 24 by 24 CSS pixels, or enough space around a smaller one that a 24px circle on it touches no other target. - Buttons are at least 24px in every size and density: `icon-xs` and `xs` are 24px, `sm` 28px, the default 32px, and compact density never takes a control below 24px. On touch screens every control grows by 8px. - Controls drawn smaller than 24px widen their hit area with an invisible pseudo-element, so the pointer reaches 24px or more while the drawing stays small: checkboxes and radios (16px drawn), a chip's remove button (16px drawn, inside a 20px chip), slider thumbs, the switch, and a data table's sort buttons (20px tall). On a coarse pointer, checkboxes, radios, switches and slider thumbs grow to 40px. - Links inside a sentence are exempt, as the criterion allows. If you build a small control of your own, give it the same treatment: `relative after:absolute after:-inset-1` on a 16px button makes a 24px target without moving anything. Make sure no ancestor clips it with `overflow: hidden`. ## Announcements For a change a screen reader wouldn't otherwise hear (a chip removed, a selection cleared), `useAnnouncer` returns `announce(text)` and an `announcer` element to render once, from the first render, since several screen readers ignore a live region that arrives with its message already inside. Saying the same thing twice is still announced. ```tsx import { useAnnouncer } from '@youj/react' function Tags({ tags, onRemove }: { tags: string[]; onRemove: (tag: string) => void }) { const { announce, announcer } = useAnnouncer() return ( <> {announcer} {tags.map((tag) => ( ))} ) } ``` `useLiveRegion` is the same idea for regions you render yourself: it returns `politeRef`, `assertiveRef` and `announce(message, { assertive, delay })`, writes straight to the node, and so never re-renders the component. Attach the refs to two `sr-only` elements (`role="status"` and `role="alert"`) that exist from the first render. ## Conformance What's tested automatically, on every change in CI, against the built Storybook: - **axe** ([axe-core](https://github.com/dequelabs/axe-core), its default rule set: WCAG 2 A and AA plus best practices) over every story, in light and dark mode. - **Right to left.** axe again over every UI, block and example story with the document in Arabic: `dir="rtl"`, `lang="ar"`, Base UI's direction and Youj's Arabic labels. The Storybook toolbar's Direction control shows the same setup. - **Target size** (2.5.8) over every UI story, measured by hit-testing what a pointer actually reaches, so widened hit areas count. - **Unit tests** for keyboard behavior, ARIA attributes, announcements and reduced motion, per component. A short list of accepted findings is kept in the repository (`tests/storybook/a11y-known.json`, explained in the suite's README); any new finding fails the build. What isn't automated: - Screen-reader behavior with real assistive technology (VoiceOver, NVDA, JAWS, TalkBack). Components are built to the WAI-ARIA patterns and their announcements are unit-tested, but manual screen-reader passes aren't run on a schedule or recorded. - Zoom and reflow (200%, 400%, 320px wide), text spacing, and forced-colors (Windows high contrast) mode. - Anything in your app: content, heading structure, flows, and the pages you compose. Automated tools find only part of what WCAG covers, so passing them doesn't make a product conformant. Youj has **no published VPAT or Accessibility Conformance Report (ACR)** yet, and no third-party audit. If you need one for procurement, for example under the European Accessibility Act, test your own product; the results above are a starting point, not a claim of conformance. Found a barrier? [Open an issue](https://github.com/youj-dev/ui/issues/new) with the component, the browser and assistive technology, and the steps. --- # Coming from shadcn/ui > Move a shadcn/ui app onto Youj: install and import from @youj/react, then swap components at your own pace. Source: https://www.youj.dev/docs/guides/shadcn `@youj/react` is built on shadcn/ui's `base-nova` style and Base UI, with Youj tokens and motion added. Its components keep shadcn's names, and its props where Base UI allows. Older shadcn styles are built on Radix, so some props change when you swap a component: [What changes](#what-changes) lists them, and which ones fail silently. Youj stays grateful to shadcn: see [Credits](https://www.youj.dev/docs/contributing#credits-and-licenses). You install and import from `@youj/react`, not from files copied into your app, so there is one import source to keep in step. Steps 1 and 2 go together, in one change: the install switches the palette, and the rename keeps your hover states right. Steps 3 and 4 can wait, and your existing components keep working between them. ## 1. Install the package and its token bridge ```bash npx @youj/cli init ``` This installs `@youj/react`, adds its stylesheet after Tailwind and wraps the app in `YoujProvider`. [Installation](https://www.youj.dev/docs/installation) covers doing it by hand. The components' stylesheet brings the palette, the tokens and the motion presets, and the shadcn bridge, `@youj/react/shadcn.css`, maps every shadcn variable onto a Youj token. Both go right after Tailwind, the bridge last: ```css title="app/globals.css" @import 'tailwindcss'; @import '@youj/react/styles.css'; @import '@youj/react/shadcn.css'; ``` Without the bridge, your existing shadcn components (`bg-background`, `bg-primary` and the rest) render unstyled. `shadcn init` left three things in that file that would override the bridge, and `init` removes them. In a terminal it shows the diff and asks first; with `--yes` the diff is in its summary. By hand, delete them: - **`@custom-variant dark (&:is(.dark *));`** Youj defines its own `dark` variant on `data-theme`. Tailwind keeps the last definition, so shadcn's would win, and every `dark:` class in Youj's components would match nothing. - **The color lines in `@theme inline`**, above all `--color-accent: var(--accent)`. Once shadcn's variables are gone, it makes `bg-accent` transparent, in your code and in Youj's components. Youj's stylesheet maps the same names itself, and sizes the radii (the `--radius-*` lines). Lines of your own in the block, such as `--font-sans`, stay. - **The `:root` and `.dark` variable blocks.** Loaded after the bridge, they win, and the palette splits in two. A variable of your own in them stays. `npx @youj/cli doctor` fails on any of the three. Your components keep their class names; only the values behind them change: ```tsx 'use client' // Plain markup that uses only shadcn's token names. The bridge in ui.css // resolves each one to a youj token, in both themes. const CHART = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4', 'bg-chart-5'] const USAGE = [40, 27, 17, 10, 6] // percent of seats, by project const INVITES = [ { email: 'ana@youj.dev', role: 'Member' }, { email: 'jonas@youj.dev', role: 'Viewer' }, ] export default function TokenBridge() { return (

    Invite your team

    Teammates get access to every project in youj-web.

      {INVITES.map((invite) => (
    • {invite.email} {invite.role}
    • ))}
    {CHART.map((chart, i) => ( ))}
    Seats used across projects
    ) } ``` That panel is plain markup using only shadcn's names. Switch between light and dark to see both palettes. The mapping is `--background` and `--foreground` to `canvas` and `fg`, `--card` and `--popover` to `surface` and `overlay`, `--primary` to the one accent, `--muted` and `--muted-foreground` to `band` and `fg-muted`, `--destructive` to `danger`, and `--border`, `--input` and `--ring` to `line`, `line-control` and `focus` (the full table is in [Theming](https://www.youj.dev/docs/theming#coming-from-shadcn)). Retune them through a Youj theme rather than by editing the bridge: [Theming](https://www.youj.dev/docs/theming) derives every value from a few inputs, in both modes. Youj's palette, and its `dark:` variant, follow a `data-theme` attribute on ``, not shadcn's `.dark` class. With `next-themes`, set `attribute="data-theme"`, or write both with `attribute={['class', 'data-theme']}` if other code still reads the class. ## 2. Rename `accent` to `hover` The two libraries use `accent` for different things. In shadcn it's the subtle hover surface behind menu items and ghost buttons. In Youj it's the theme's one accent color, such as Basalt's amber or Graphite's ultramarine. Keep the shadcn meaning under a new name, `hover`, renaming whole class tokens only: | shadcn class | Youj class | | --- | --- | | `bg-accent`, `hover:bg-accent`, `data-highlighted:bg-accent/50` | `bg-hover`, `hover:bg-hover`, `data-highlighted:bg-hover` | | `text-accent-foreground` | `text-fg` | | `bg-sidebar-accent`, `text-sidebar-accent-foreground` | `bg-hover`, `text-fg` | | `var(--accent)`, `var(--accent-foreground)` | `var(--color-hover)`, `var(--color-fg)` | Skip this step and hover states turn the accent color, and `text-accent-foreground` generates no class at all. To find every use, run this in your app and rename the ones that mean the hover surface: ```bash grep -rnE "accent|--accent" app components src --include='*.tsx' --include='*.ts' --include='*.css' ``` Afterwards, with shadcn's `--color-accent` line gone from `@theme inline` (step 1), any `bg-accent` left in your code means Youj's accent, used on purpose. ## 3. Swap tw-animate for the presets shadcn animates overlays with tw-animate classes. Youj replaces them with presets that follow the Speed setting and reduced motion, and that reverse mid-flight. Remove the tw-animate classes (`data-open:animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*`, `duration-*`) from the overlays you still own and add one preset, such as `yj-pop` for popovers and menus or `yj-dialog` for dialogs. [Overlay presets](https://www.youj.dev/docs/motion/presets) lists every preset and what it goes on. Components you take from `@youj/react` already have them. ## 4. Replace components when you're ready Swap components one at a time, whenever a Youj version earns its place. Change the import, and delete your copy of the file once nothing uses it: ```tsx // before import { Button } from '@/components/ui/button' // after import { Button } from '@youj/react' ``` Components that need an optional peer come from their own entry: `@youj/react/chart`, `/data-table`, `/form`, `/date`, `/carousel` and `/command`. Each component's page shows its install command and import, and `npx @youj/cli add chart` installs the peer for you. ## What changes Youj's components are built on Base UI, like shadcn's `base-nova` style. Coming from a Radix-based style (`new-york`, `radix-nova`), these change when you swap the import. Every one fails loudly, with a type error or a development warning: nothing compiles and then quietly misbehaves. The ban list is a type test (`shadcn-traps.test.tsx`), so it can't regress. | shadcn (Radix) | Youj | Unchanged, it… | | --- | --- | --- | | ``, and the same on the checkbox, radio and sub-trigger items, `ContextMenuItem` and `MenubarItem` | `onClick={fn}` | type error that names `onClick`: `onSelect` is the DOM's text-selection event and never fires on a menu item | | `` with string state | the handler gets `string \| null`: `onValueChange={(value) => value && setPlan(value)}` | type error | | `asChild` on `Button`, the triggers (`DialogTrigger`, `TooltipTrigger`, `PopoverTrigger`, `SheetTrigger`, `CollapsibleTrigger`, `DropdownMenuTrigger`), `DialogClose`, `NavigationMenuLink` | `render`: `
    {/* onAction runs the work and shows its whole lifecycle in place. */} { await wait(1600) setPublished(true) }} pendingLabel="Publishing…" successLabel="Published" errorLabel="Couldn’t publish" disabled={published} > {published ? 'Published' : 'Publish'} {!published && }
    ) } ``` A publish card whose Publish button runs its own async action, beside Discard and Export. ### Variants ```tsx 'use client' import type { ReactNode } from 'react' import { Button } from '@youj/react' import { ArrowUpRightIcon, DownloadIcon, PlusIcon, TrashIcon, UserPlusIcon } from '@youj/icons' export default function ButtonVariants() { return (
    ) } function Variant({ name, note, children }: { name: string; note: string; children: ReactNode }) { return (
    {children}

    {name}

    {note}

    ) } ``` The six variants, each with a note on when to use it. One `default` button per view carries the accent. `secondary` and `outline` sit beside it, `ghost` handles quiet actions and dismissals, and `danger` marks the action that deletes or revokes. `link` is an action styled as text. It's still a button. Youj icons inside a button play when the button is hovered or keyboard-focused, with no wiring. An icon at either end sits 2px closer to its edge than text would, which reads as even padding. ### Sizes ```tsx 'use client' import { Button } from '@youj/react' import { PlusIcon, RocketIcon } from '@youj/icons' const TEXT = [ { size: 'xs', height: '24' }, { size: 'sm', height: '28' }, { size: 'default', height: '32' }, { size: 'lg', height: '40' }, ] as const const ICON = [ { size: 'icon-xs', height: '24' }, { size: 'icon-sm', height: '28' }, { size: 'icon', height: '32' }, { size: 'icon-lg', height: '40' }, ] as const export default function ButtonSizes() { return (
    {TEXT.map(({ size, height }) => (
    {size} · {height}px
    ))}
    {ICON.map(({ size, height }) => (
    {/* Icon-only buttons are named by aria-label. */}
    {size} · {height}px
    ))}
    ) } ``` Text sizes from extra small to large, and the four matching icon sizes. Four text sizes and four square icon sizes sit on the control-height scale: 24, 28, 32 and 40px. On touch screens (`pointer: coarse`), every size is 8px taller and a default button is never under 44px, so it is a 44px target without a prop. The extra-small and small sizes stay compact and reach 44px with an invisible hit area around them. An icon-only button has no visible text, so it needs an `aria-label`. ### Async ```tsx 'use client' import { Button } from '@youj/react' import { DownloadIcon, ListChecksIcon } from '@youj/icons' const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default function ButtonAsync() { return (

    Usage report

    September, all projects. 14,208 rows.

    {/* onClick returns a promise: the button is busy until it settles. No state to keep. */}
    ) } ``` Export CSV and Run checks buttons that stay busy while their onClick promise is pending. Return a promise from `onClick` and the button looks after the wait: it shows its loading state (a spinner in place of the label, `aria-busy`, presses blocked, focus kept) until the promise settles, then returns to normal. There is no prop and no state to keep. A rejected promise ends the wait too, and the error surfaces as it would for any unawaited handler, so catch it in the handler if you need to show it. An explicit `loading` prop always wins. ```tsx ``` ### Hold to confirm ```tsx 'use client' import { AsyncButton } from '@youj/react' import { TrashIcon } from '@youj/icons' const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default function ButtonHold() { return (

    Delete “atlas-web”

    Removes 214 deployments and every domain.

    {/* Runs only once the press is held: the fill sweeps across, letting go early springs it back. */} wait(1200)} pendingLabel="Deleting…" successLabel="Deleted"> Delete project
    ) } ``` A danger zone whose Delete project button runs only after the press is held, then confirms as Deleted. Hold to confirm lives on `AsyncButton`, not on the plain `Button`: pass `hold` and the action runs only once a press is held, by a pointer or with Space or Enter held down. While held, a fill sweeps across from the inline start, so the press shows how long is left. Letting go early springs the fill back and nothing runs. When the fill is full the action starts and the button goes through its usual lifecycle: the spinner, then the success tint with the drawn check and `successLabel`, held, then back to rest (or the error, if it fails). The press sound plays as the hold completes, not on the press. `hold` (or `hold={true}`) takes twice the `deliberate` motion token, scaled by the provider's speed; a number is milliseconds. ```tsx deleteProject(id)} pendingLabel="Deleting…" successLabel="Deleted"> Delete project ``` ```tsx 'use client' import { AsyncButton } from '@youj/react' const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default function AsyncButtonHold() { return (

    €1,240.00 to Northwind Ltd

    Invoice INV-2041 · arrives today

    {/* A full hold runs the action; then Sending… → Sent, as any AsyncButton. */} wait(1400)} pendingLabel="Sending…" successLabel="Sent" errorLabel="Payment declined"> Send payment
    ) } ``` ```tsx sendPayment(invoice)} pendingLabel="Sending…" successLabel="Sent"> Send payment ``` Use a hold for actions that are easy to trigger by mistake and costly to undo, where a dialog would be too much. For a slower, more deliberate gesture, use [Slide to Confirm](https://www.youj.dev/docs/components/slide-to-confirm). ### Loading ```tsx 'use client' import { useState } from 'react' import { Button } from '@youj/react' import { DownloadIcon, ListChecksIcon } from '@youj/icons' const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default function ButtonLoading() { const [exporting, setExporting] = useState(false) const [checking, setChecking] = useState(false) return (

    Usage report

    September, all projects. 14,208 rows.

    {/* `loading` is controlled: you decide when the wait starts and ends. */}
    ) } ``` Two buttons whose loading state is set from state you own. `loading` is the controlled alternative, for work that starts somewhere else or outlives the press. The label hands over to a spinner without changing the button's width, presses are blocked, and focus stays on the button. Pass `loading` (even as `false`) from the first render on any button that can load. Its live region then exists before it has anything to say, which some screen readers need before they'll announce a change. ## Installation & usage ```bash pnpm add @youj/react ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add button ``` It brings `spinner`, `youj-core`, `youj-labels-button`, `youj-presets`, `youj-ui-core` with it. Use a button to save, submit, open a dialog or start another action on the page. For navigation, use a link; `buttonVariants` gives it the same look. For an option that stays on or off, use a [Toggle](https://www.youj.dev/docs/components/toggle) or a [Switch](https://www.youj.dev/docs/components/switch). For work that runs on press and shows its outcome in the button, see [Async actions](#async-actions). ```tsx import { Button } from '@youj/react' export function SaveBar() { return (
    ) } ``` It renders a native ` ``` A `render` element with an `href` or `to` (an anchor, Next.js `Link`, React Router `Link`) or another tag such as `span` sets `nativeButton={false}` for you, so Base UI doesn't expect a ` {/* A split button: filled buttons have no border, so a separator draws the seam. */} }> Create a merge commit Squash and merge Rebase and merge ) } ``` Nested groups of icon and text buttons, and a split Merge button with a menu half. Filled buttons have no border, so put a `ButtonGroupSeparator` between them to draw the seam. The menu half is a [Dropdown Menu](https://www.youj.dev/docs/components/dropdown-menu) whose trigger renders as a `Button`. ### With inputs ```tsx 'use client' import { MinusIcon, PlusIcon, SearchIcon } from '@youj/icons' import { Button, ButtonGroup, ButtonGroupText, Input } from '@youj/react' export default function ButtonGroupInputs() { return (
    {/* ButtonGroupText renders as a label, so the prefix names the input. */} }>https://
    ) } ``` A search input with its button, a URL input with a text prefix, and a vertical zoom pair. Inputs, selects and a `ButtonGroupText` join the same strip. `ButtonGroupText` renders any element through `render`, so a prefix can be the input's `label`. Set `orientation="vertical"` to stack the members. ## Installation & usage ```bash pnpm add @youj/react ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add button-group ``` It brings `separator`, `youj-ui-core` with it. Use a button group when a few actions belong together and should read as one control: previous and next, a split button with its options, a search field with its button. For a set of options where one is chosen, use a [Toggle Group](https://www.youj.dev/docs/components/toggle-group). For a long, dense row of controls that should be one tab stop, use a [Toolbar](https://www.youj.dev/docs/components/toolbar). ```tsx import { Button, ButtonGroup } from '@youj/react' export function Pager() { return ( ) } ``` The group takes away the inner corners and the doubled border where members meet, and a focused member rises above its neighbors so its ring isn't clipped. Corners are start and end, not left and right, so a group mirrors in right-to-left layouts. Nest groups to make clusters with a gap between them. The API matches shadcn/ui's Button Group. ## Description Each example's description is under Examples. ## Code Each example's code is under Examples. ## Reference ### Props #### ButtonGroup | Prop | Type | Default | Description | | --- | --- | --- | --- | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | | #### ButtonGroupSeparator Takes the props of `ComponentProps`. `ButtonGroupText` takes `render` and any `div` props. ### Reduced motion The group itself doesn't move. Its buttons keep their own behavior: under reduced motion they don't scale when pressed. ### Accessibility - The group is a `group`; give it an `aria-label` when its purpose isn't obvious from its buttons. - Every member keeps its own role, name and tab stop. If the group is long and dense enough that tabbing through it is a chore, use a [Toolbar](https://www.youj.dev/docs/components/toolbar) instead, which is one tab stop. - The separator is a `separator` with `aria-orientation`. Name icon-only buttons with `aria-label`. --- # Toggle > Turn an option on or off with a button that stays pressed. Source: https://www.youj.dev/docs/components/toggle ## Examples ### Demo ```tsx 'use client' import { useState } from 'react' import { Toggle } from '@youj/react' import { cn } from '@youj/core' import { ClockIcon, CornerDownLeftIcon, PinIcon } from '@youj/icons' const LOG = [ { time: '14:02:11.204', line: 'Cloning github.com/youj/web (branch main, commit 4f2a91c)' }, { time: '14:02:13.871', line: 'Installing dependencies from pnpm-lock.yaml with pnpm 10.34' }, { time: '14:02:26.040', line: 'Compiled 48 routes in 11.8s, 3 of them static with a 60 second revalidation window' }, { time: '14:02:39.512', line: 'Deployed to production at youj.io' }, ] export default function ToggleDemo() { const [timestamps, setTimestamps] = useState(true) const [wrap, setWrap] = useState(false) const [pinned, setPinned] = useState(false) return (

    Build logs

    Timestamps Wrap {/* Icon-only: the name stays the same, and aria-pressed carries the state. */}
      {LOG.map(({ time, line }) => (
    1. {timestamps && {time}} {line}
    2. ))}
    ) } ``` Build log options: Timestamps and Wrap toggles with labels, and an icon-only Pin toggle. ### State icons ```tsx 'use client' import { useState } from 'react' import { Toggle } from '@youj/react' import { BookmarkIcon, EyeIcon, MicIcon, PinIcon, type YoujIconComponent } from '@youj/icons' const TOGGLES: { id: string; label: string; Icon: YoujIconComponent; states: [string, string] }[] = [ { id: 'pin', label: 'Pin project', Icon: PinIcon, states: ['Unpinned', 'Pinned'] }, { id: 'save', label: 'Save to reading list', Icon: BookmarkIcon, states: ['Not saved', 'Saved'] }, { id: 'mute', label: 'Mute microphone', Icon: MicIcon, states: ['Live', 'Muted'] }, { id: 'hide', label: 'Hide resolved threads', Icon: EyeIcon, states: ['Shown', 'Hidden'] }, ] export default function ToggleIcons() { const [pressed, setPressed] = useState>({ pin: true }) return (
    {TOGGLES.map(({ id, label, Icon, states }) => { const on = Boolean(pressed[id]) return (
    {/* A state icon follows `pressed` through its `active` prop, and reverses mid-morph. */} setPressed((p) => ({ ...p, [id]: next }))} > {states[on ? 1 : 0]}
    ) })}
    ) } ``` Four icon toggles whose icons morph between their two states as you press. A Youj state icon inside a toggle can follow the pressed state through its `active` prop. It morphs to its second state as the toggle presses, and a quick second press reverses it from wherever it is. ```tsx ``` ### Variants and sizes ```tsx 'use client' import type { ReactNode } from 'react' import { Toggle } from '@youj/react' import { FilterIcon, ItalicIcon, UnderlineIcon } from '@youj/icons' export default function ToggleVariants() { return (
    Failed only
    ) } function Specimen({ label, children }: { label: string; children: ReactNode }) { return (
    {children}

    {label}

    ) } ``` The default and outline variants, a labeled toggle, the three sizes and a disabled toggle. `default` is borderless, for toolbars that already have a frame. `outline` adds a border and suits toggles that stand alone. Three sizes, `sm`, `default` and `lg`, match the button heights (28, 32 and 40px). ## Installation & usage ```bash pnpm add @youj/react ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add toggle ``` It brings `youj-core`, `youj-presets`, `youj-ui-core` with it. Use a toggle for a view or formatting option that applies at once and is read from the button itself: pressed means on. For a setting in a form or a settings list, use a [Switch](https://www.youj.dev/docs/components/switch). For a set of related options, use a [Toggle Group](https://www.youj.dev/docs/components/toggle-group), and for a one-off action, a [Button](https://www.youj.dev/docs/components/button). ```tsx import { useState } from 'react' import { Toggle } from '@youj/react' import { CornerDownLeftIcon } from '@youj/icons' export function WrapLines() { const [wrap, setWrap] = useState(false) return ( Wrap ) } ``` Use `pressed` and `onPressedChange` to control it, or `defaultPressed` to let it hold its own state. Pressed reads as a filled neutral key with full-strength text. The accent stays reserved for actions. ## Description Each example's description is under Examples. ## Code Each example's code is under Examples. ## Reference ### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `sound` | `boolean` | `true` | With sound on (`YoujProvider`’s `sound`), pressing plays `toggleOn` and releasing `toggleOff`. `false` keeps this one silent. | | `variant` | `'default' \| 'outline'` | `'default'` | | | `size` | `'default' \| 'sm' \| 'lg'` | `'default'` | | Also takes the props of `TogglePrimitive.Props`, `VariantProps`. The state comes from Base UI's Toggle, and these are the props you'll reach for: | Prop | Type | Description | | --- | --- | --- | | `pressed` | `boolean` | Controlled state. | | `defaultPressed` | `boolean` | Initial state when uncontrolled. Default `false`. | | `onPressedChange` | `(pressed: boolean, details) => void` | Called when the user toggles it. | | `disabled` | `boolean` | Ignores presses. | | `value` | `string` | Identifies it inside a Toggle Group. | ### Motion | Moment | Tokens | Value | Note | | --- | --- | --- | --- | | Press | `duration.instant`, `ease.standard` | 80ms; cubic-bezier(0.2, 0, 0, 1) | scales to 0.97 while held | | Release, hover and pressed fill | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | scale back, background and text colors | **Reduced motion:** No press scale. The fill and text colors still change on the fast token, since nothing moves. #### Reduced motion The press doesn't scale. Colors still change, so the pressed state reads the same, and a state icon switches to its second state instantly instead of morphing. ### Accessibility - It's a ` default Monthly Yearly lg ) } ``` The three sizes beside each other. `size` is `sm`, `default` or `lg`. Each is the height of the matching control, so a segmented control sits in a row with inputs and buttons of the same size. Icon-only segments need an `aria-label`. Give the control `className="w-full"` to stretch it, and the segments share the width. ## Installation & usage ```bash pnpm add @youj/react ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add segmented-control ``` It brings `toggle-group`, `use-controllable-state`, `youj-core` with it. Use a segmented control for a small, fixed set of options where one is always chosen and the choice changes a view in place: a time range, a billing period, a layout. It's a thin wrapper over [Toggle Group](https://www.youj.dev/docs/components/toggle-group)'s `segmented` look (plain labels on a hairline, a 2px accent bar under the pressed one) with a simpler contract: the value is one string, and it can't be emptied. Reach for something else when the shape is different: - **[Toggle Group](https://www.youj.dev/docs/components/toggle-group)** when several options can be on at once (bold, italic, underline), or when the choice may be cleared. - **[Tabs](https://www.youj.dev/docs/components/tabs)** when each option owns a panel of content that the others hide. - **[Radio Group](https://www.youj.dev/docs/components/radio-group)** for a choice inside a form that's submitted later. - **[Select](https://www.youj.dev/docs/components/select)** when there are more options than fit in a row. ```tsx import { SegmentedControl, SegmentedControlItem } from '@youj/react' export function RangePicker() { const [range, setRange] = useState('7d') return ( 24h 7d 30d ) } ``` `onValueChange` receives the pressed segment's value as a string. Pressing the segment that's already pressed does nothing, so there's always a choice. `variant="pill"` sets the segments in a recessed track with a raised key instead, which suits a control that sits inside a capsule or on a well. Leave `defaultValue` out to start with nothing pressed; the first press picks one. ## Description Each example's description is under Examples. ## Code Each example's code is under Examples. ## Reference ### Props #### SegmentedControl | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `'underline' \| 'pill'` | `'underline'` | `underline` (a bar on a hairline) or `pill` (a raised key in a recessed track). | | `value` | `string` | | The pressed segment's value (controlled). | | `defaultValue` | `string` | `''` | The segment pressed at first (uncontrolled). Leave it out to start with none pressed. | | `onValueChange` | `(value: string) => void` | | Called with the newly pressed segment's value. Never called with an empty value. | | `aria-label` | `string` | | Names the control for assistive tech, e.g. "View" or "Range". | | `sound` | `boolean` | `true` | With sound on (`YoujProvider`’s `sound`), a new choice plays `select`. `false` keeps this one silent. | | `size` | `'default' \| 'sm' \| 'lg'` | | | Also takes the props of `ToggleGroupProps`. It also takes Toggle Group's `size`, `orientation`, `loopFocus` and `disabled`, and the props of a `div`. #### SegmentedControlItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | `size` | `'default' \| 'sm' \| 'lg'` | | | | `value` (required) | `string` | | The value this segment stands for. | Each segment takes a `value` and `disabled`, and the props of a `button`. ### Motion | Moment | Tokens | Value | Note | | --- | --- | --- | --- | | Indicator | `spring.smooth` | 0.36s, bounce 0 | glides to the pressed segment and retargets if you press again mid-glide | | Indicator appears | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | when a control with no value gets its first | | Press | `duration.instant`, `ease.standard` | 80ms; cubic-bezier(0.2, 0, 0, 1) | each segment scales to 0.97 while held | | Hover and text color | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | | **Reduced motion:** The indicator jumps to the pressed segment, and segments don't scale when pressed. The bar (or the key, in `pill`) is one [Sliding Indicator](https://www.youj.dev/docs/motion/sliding-indicator): transform-only layout projection, so its corners stay round in flight and nothing around it reflows. Resizes and font loads move it into place without animating. #### Reduced motion The indicator moves straight to the pressed segment, and presses don't scale. Which segment is pressed reads exactly the same. ### Accessibility - The control is a `group` named by its `aria-label`, and each segment is a `
    Save changes ) } ``` A project form with a name and a URL, validated with zod as each field is touched, and a Save button that shows the submission. Clear the name in the demo and press Save. The error grows in under the field, focus lands on it, and the button shakes to "Check the form". Enter in any field presses `FormSubmit`, so the keyboard gets the same states as a click, and a second press while the submission runs is ignored. On an invalid submit, focus moves to the first invalid control in document order and scrolls it to the middle of the view: smoothly, or at once under reduced motion. `mode: 'onTouched'` validates each field as it's left, which is the gentlest default for long forms. A rule only the server knows, such as a URL that's already taken, comes back as a field error. Set it with `form.setError` inside `onSubmit`, then throw: the message appears on its field, focus moves there, and the button shows its `errorLabel`. Type "taken" in the demo's URL field to see it. An `onSubmit` that throws without setting a field error shakes the button to `errorLabel` and leaves the fields as they were. ```tsx onSubmit={async (values) => { const result = await api.save(values) if (result.error === 'slug_taken') { form.setError('slug', { message: `orion.dev/${values.slug} is already in use.` }) throw new Error('slug_taken') } }} ``` ### Pickers ```tsx 'use client' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { z } from 'zod' import { FieldGroup, TagInput, TimePicker } from '@youj/react' import { Combobox } from '@youj/react/command' import { DatePicker } from '@youj/react/date' import { Form, FormField, FormSelect, FormSubmit } from '@youj/react/form' const OWNERS = [ { value: 'ada', label: 'Ada Lovelace', description: 'ada@orion.dev' }, { value: 'grace', label: 'Grace Hopper', description: 'grace@orion.dev' }, { value: 'dieter', label: 'Dieter Rams', description: 'dieter@orion.dev' }, { value: 'susan', label: 'Susan Kare', description: 'susan@orion.dev' }, ] const REGIONS = [ { value: 'fra', label: 'Frankfurt' }, { value: 'iad', label: 'Washington, D.C.' }, { value: 'sin', label: 'Singapore' }, ] // The pickers hold null while empty, so each required one refines it away. const schema = z.object({ owner: z .string() .nullable() .refine((v) => v !== null, 'Someone has to own the launch.'), region: z .string() .nullable() .refine((v) => v !== null, 'Pick where it launches.'), launch: z .date() .nullable() .refine((v) => v !== null, 'Pick a launch date.'), time: z .string() .nullable() .refine((v) => v !== null, 'Pick a time.'), labels: z.array(z.string()).min(1, 'Add at least one label.'), }) type Values = z.input const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export default function FormPickers() { const form = useForm>({ resolver: zodResolver(schema), defaultValues: { owner: null, region: null, launch: null, time: '10:00', labels: [] }, mode: 'onTouched', }) return (
    wait(900)} className="w-full max-w-sm"> {(field) => ( )}
    {(field) => ( )} {(field) => ( )}
    {(field) => ( )}
    Schedule launch
    ) } ``` Pickers mapped onto a form field. `FormField` passes its render function react-hook-form's `field`. Spread it onto an [Input](https://www.youj.dev/docs/components/input). The pickers take `value` and `onValueChange` instead of an event, so map the parts: `ref`, `value`, `onValueChange={field.onChange}` and `onBlur`. Empty pickers hold `null`, so a required one refines it away in the schema. ```tsx {(field) => ( )} ``` Passing `ref` matters: it's how the form finds the control to focus when it's invalid. ## Installation & usage ```bash pnpm add @youj/react react-hook-form ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add form ``` It brings `async-button`, `field`, `select`, `youj-core`, `youj-labels-form` with it. The copy is 26 registry items, about 9,700 lines in all, 5,256 of them the shared `youj-core`. Beyond the shared ones it brings `async-button`, `async-feedback`, `button`, `collapse`, `field`, `icon-check`, `icon-chevron-down`, `icon-chevron-up`, `icon-engine`, `icon-loader-ring` and 7 more. Requires `react-hook-form`, an [optional dependency](https://www.youj.dev/docs/installation#optional-dependencies), and the examples below also use `zod` and `@hookform/resolvers` (`pnpm add react-hook-form zod @hookform/resolvers`). Import it from `@youj/react/form`. `Form` builds on [react-hook-form](https://react-hook-form.com). Validate with any resolver: `zodResolver(schema)` from `@hookform/resolvers` is the usual one. Use `Form` when you need validation, such as in settings, sign-in or a create flow. It connects labels and errors, focuses the first invalid control, and shows submission states in the button. For a field without validation, wrap an [Input](https://www.youj.dev/docs/components/input) in a [Field](https://www.youj.dev/docs/components/field). Install what it needs first, or the build fails on a missing export: With pnpm, npm, Yarn or Bun: ```bash pnpm add react-hook-form @hookform/resolvers zod npm install react-hook-form @hookform/resolvers zod yarn add react-hook-form @hookform/resolvers zod bun add react-hook-form @hookform/resolvers zod ``` In the Next.js App Router, render this from a Client Component, a file that starts with `'use client'`: it takes functions (`onSubmit` and a `resolver`), and a Server Component can't pass those. [Server rendering](https://www.youj.dev/docs/guides/ssr) has the rest. ```tsx import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { z } from 'zod' import { FieldGroup, Input } from '@youj/react' import { Form, FormField, FormSubmit } from '@youj/react/form' const schema = z.object({ name: z.string().trim().min(3, 'Use at least 3 characters.') }) export function RenameProject() { const form = useForm({ resolver: zodResolver(schema), defaultValues: { name: 'Orion' }, mode: 'onTouched' }) return (
    api.rename(values)}> {(field) => } Save
    ) } ``` `Form` takes the `useForm()` return value and an `onSubmit` that receives the resolver's output. Resolve for success, and reject or throw for failure. `FormField` is a [Field](https://www.youj.dev/docs/components/field) around one controlled value: its `label` names the control, its `description` and error describe it, and the control is marked invalid while the error shows. `FormSubmit` is an [Async Button](https://www.youj.dev/docs/components/button#async-actions) that validates and then runs `onSubmit`. The parts are shadcn's: `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription` and `FormMessage`. The shorthand above lays them out for you; to place them yourself, or to move a shadcn form over, give `FormField` a `render` (see [shadcn's parts](#shadcns-parts)). The difference from shadcn's Form: `Form` takes the `useForm()` return value as `form` and an `onSubmit`, and renders the `
    ` itself, so there is no `` to write. ### shadcn's parts Give `FormField` a `render` and it only provides the field to its parts, as shadcn's does. `FormItem` is a [Field](https://www.youj.dev/docs/components/field): it groups the label, control, description and message. `FormControl` marks its control invalid, `FormMessage` shows the field's error and `useFormField()` reads the name, validity and error for a part of your own. ```tsx import { Input } from '@youj/react' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormSubmit } from '@youj/react/form' api.rename(values)}> ( Project name Shown on the dashboard. )} /> Save ``` The `label` and `description` props and the `children` function are the shorthand for exactly this: a `FormItem` with a `FormLabel`, your control, a `FormDescription` and a `FormMessage`. Use `render` when you want a different order, or no label. ### Select `FormSelect` is a `FormField` with a [Select](https://www.youj.dev/docs/components/select) in it, built from `options`. It wires the field's value, change, blur and focus target for you, and takes the same `name`, `label`, `description` and `required`. An empty select holds `null`, so a required one refines it away in the schema (see [Required pickers with zod](#required-pickers-with-zod)). ```tsx import { Form, FormSelect } from '@youj/react/form' const regions = [ { value: 'fra', label: 'Frankfurt' }, { value: 'iad', label: 'Washington, D.C.' }, { value: 'sin', label: 'Singapore', disabled: true }, ] ``` For grouped or custom items, compose a `Select` inside `FormField` and map the parts as below: `value`, `onValueChange` and `items` on `Select`, and `ref` and `onBlur` on `SelectTrigger`. ### Checkbox and Radio Group A [Checkbox](https://www.youj.dev/docs/components/checkbox) holds a boolean and a [Radio Group](https://www.youj.dev/docs/components/radio-group) a string, and both take a value and a change handler instead of an event. Map `value` to `checked` for the checkbox, and `onChange` to `onCheckedChange` and `onValueChange`. Start the checkbox at `false` in `defaultValues`; an unchosen radio group holds `undefined`. ```tsx import { Checkbox, Field, FieldLabel, RadioGroup, RadioGroupItem } from '@youj/react' {(field) => ( )} {(field) => ( Free Pro )} ``` ### Required pickers with zod An empty picker or select holds `null`, so the form's values (the schema's input) allow `null` while the submitted values (its output) don't. Two things make TypeScript see that: - **Refine with a type guard.** `(value): value is string => value !== null` narrows the output to `string`. A refine that only returns a boolean keeps `string | null`. - **Give `useForm` the input and the output.** Let it infer both from `zodResolver(schema)`, or name all three generics: `useForm, unknown, z.output>`. `useForm>` names only the output, so `defaultValues: { region: null }` and the resolver no longer type-check. ```tsx import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { z } from 'zod' import { FieldGroup } from '@youj/react' import { Combobox } from '@youj/react/command' import { Form, FormField, FormSelect, FormSubmit } from '@youj/react/form' const chosen = (value: string | null): value is string => value !== null const schema = z.object({ region: z.string().nullable().refine(chosen, 'Choose a region.'), owner: z.string().nullable().refine(chosen, 'Choose an owner.'), }) export function CreateService() { // Values hold null while empty (the input); onSubmit gets strings (the output). const form = useForm, unknown, z.output>({ resolver: zodResolver(schema), defaultValues: { region: null, owner: null }, mode: 'onTouched', }) return (
    api.createService(values)}> {(field) => ( )} Create service
    ) } ``` `values` in `onSubmit` is `{ region: string; owner: string }`, and an empty field shows its refine message. ## Description Each example's description is under Examples. ## Code Each example's code is under Examples. ## Reference ### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `form` (required) | `UseFormReturn` | | The useForm() return value. | | `onSubmit` (required) | `(values: Output) => unknown` | | Runs with the resolver's output (a zod schema's parsed values). Resolve for success; reject (or throw) for failure. | Also takes the props of `ComponentProps<'form'>`. #### FormField | Prop | Type | Default | Description | | --- | --- | --- | --- | | `render` | `never` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | `label` | `ReactNode` | | | | `description` | `ReactNode` | | | | `children` | `(field: ControllerRenderProps, state: ControllerFieldState) => ReactNode` | | Renders the control inside the shorthand's label, description and message. Spread `field` onto an Input; map it (`value`, `onValueChange={field.onChange}`, `onBlur`, `ref`) onto the pickers. | | `name` (required) | `N` | | | | `control` | `Control` | | For type inference from useForm(); defaults to the surrounding Form's. | With `children` it also takes every [Field](https://www.youj.dev/docs/components/field) prop except `invalid`, which it sets from the field's state, such as `required`, `disabled` and `orientation`. #### FormItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | `orientation` | `'horizontal' \| 'vertical' \| 'responsive'` | | | | `required` | `boolean` | | Marks the label and makes the control required. | The other parts (`FormLabel`, `FormControl`, `FormDescription`, `FormMessage`) take the props of the [Field](https://www.youj.dev/docs/components/field) part they are named after. #### FormSelect | Prop | Type | Default | Description | | --- | --- | --- | --- | | `options` (required) | `ReadonlyArray>>>` | | The choices, in order. The trigger shows the chosen one's label. | | `placeholder` | `ReactNode` | | Shown while the value is empty (`null`). | | `size` | `'sm' \| 'default'` | ``default`` | Trigger height. | | `name` (required) | `N` | | | | `orientation` | `'horizontal' \| 'vertical' \| 'responsive'` | | | | `label` | `ReactNode` | | | | `render` | `never` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | `required` | `boolean` | | Marks the label and makes the control required. | | `description` | `ReactNode` | | | | `control` | `Control` | | For type inference from useForm(); defaults to the surrounding Form's. | It also takes every `FormField` prop except `children`. Each option is `{ value, label, disabled? }`. #### FormSubmit | Prop | Type | Default | Description | | --- | --- | --- | --- | | `invalidLabel` | `string` | `"Check the form"` | Shown beside the cross when fields need attention. | | `variant` | `'default' \| 'danger' \| 'ghost' \| 'outline' \| 'link' \| 'secondary'` | | `default \| secondary \| outline \| ghost \| danger \| link`. | | `size` | `'default' \| 'sm' \| 'lg' \| 'xs' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | | | | `sound` | `boolean` | `true` | With sound on (`YoujProvider`’s `sound`), the button plays `tap` as it's pressed. `false` keeps this one silent. | | `pendingLabel` | `string` | | Shown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted. | | `successLabel` | `string \| ((result: TResult) => string)` | `"Done"` | Shown beside the check and announced. A function gets what `onAction` resolved with: `successLabel={(user) => `Saved ${user.name}`}`. Under `status` there is no outcome to read, so pass a string. | | `errorLabel` | `string \| ((error: unknown) => string)` | `"Failed"` | Shown beside the cross and announced assertively. A function gets what `onAction` rejected or threw with, whatever that is: `errorLabel={(error) => error instanceof Error ? error.message : 'Failed'}`. Under `status`, pass a string (`errorLabel={mutation.error?.message}`). | | `resetAfter` | `number` | | How long success / error hold before the label returns, in ms. Default: 3 × the deliberate token. | | `onActionStateChange` | `(state: AsyncState) => void` | | Fires on every state change: idle → pending → success \| error → idle. | | `hold` | `boolean \| number` | | Hold to confirm: the action runs only once a press is held (a pointer, or Space or Enter held down) for this long. `true` takes twice the deliberate motion token; a number is milliseconds. The button fills from its inline start while held and springs back if let go early; a full hold runs the action and the button carries on through pending to success or error. It is described as "Hold to confirm". | `FormSubmit` takes every [Async Button](https://www.youj.dev/docs/components/button#async-actions) prop except `onAction`, `status` and `type`, including `pendingLabel`, `successLabel`, `errorLabel` and `resetAfter`. `FormInvalidError` is the error `FormSubmit` sees when validation fails, for code that calls the submission itself. ### Motion | Moment | Tokens | Value | Note | | --- | --- | --- | --- | | Error in | `duration.base`, `ease.standard` | 220ms; cubic-bezier(0.2, 0, 0, 1) | the message grows from 0 height and fades in under its control (Field) | | Error out | `duration.fast`, `ease.exit` | 140ms; cubic-bezier(0.4, 0, 1, 1) | the text fades before the height closes | | Submit pending | `duration.fast`, `ease.standard` | 140ms; cubic-bezier(0.2, 0, 0, 1) | the label crossfades to a spinner (Async Button) | | Button width | `spring.snappy` | 0.2s, bounce 0 | follows each new label | | Submit success | `duration.slow`, `ease.emphasized` | 340ms; cubic-bezier(0.6, 0, 0.1, 1) | a check draws on beside `successLabel` | | Invalid or failed | `duration.slow`, `ease.standard` | 340ms; cubic-bezier(0.2, 0, 0, 1) | a 4, 3 and 2px shake to `invalidLabel` or `errorLabel` | | Focus first invalid | | smooth scroll | scrolls it to the middle of the view | **Reduced motion:** Errors appear and disappear at once, the button's states swap instantly without a shake, and the first invalid control is scrolled to without smooth scrolling. #### Reduced motion Errors appear and disappear without the height animation. The submit button's states swap at once and don't shake, and focusing the first invalid control scrolls straight to it. Validation, messages and focus behave exactly the same. ### Accessibility - The `
    ` sets `noValidate`: the schema validates, and messages are written in your voice rather than the browser's. - Each `FormField` label names its control, and the description and error are linked to it with `aria-describedby`. The control carries `aria-invalid` while its error shows, and `required` marks it required: native `required` on an Input, `aria-required` on custom controls such as Combobox, MultiSelect, TagInput and TimePicker. - An invalid submit moves focus to the first invalid control, with a visible focus ring, so keyboard and screen reader users land on the problem rather than having to find it. - `FormSubmit` announces pending and success politely and failures assertively, as Async Button does. Presses while it runs are ignored. --- # Field > Connect a form control to its label, description and error messages. Source: https://www.youj.dev/docs/components/field ## Examples ### Demo ```tsx 'use client' import { useRef, useState, type FormEvent } from 'react' import { Button, Checkbox, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, Input, } from '@youj/react' const SCOPES = [ { id: 'read', label: 'Read', description: 'Projects, deployments and logs.' }, { id: 'write', label: 'Write', description: 'Create deployments and change settings.' }, { id: 'admin', label: 'Admin', description: 'Members, billing and deletion. Rarely needed.' }, ] export default function FieldDemo() { const name = useRef(null) const [error, setError] = useState() const [created, setCreated] = useState('') function submit(event: FormEvent) { event.preventDefault() const value = name.current?.value.trim() ?? '' const problem = !value ? 'Give the token a name.' : value.length < 3 ? 'Use at least 3 characters.' : undefined setError(problem) if (problem) name.current?.focus() else setCreated(value) } return (
    Create an API token Tokens act as you. Give each one the least access it needs. {/* The error grows in below the input, and the fields beneath ease down to make room. */} Token name setError(undefined)} /> {error} Only you see this.
    Scopes {SCOPES.map((scope) => ( {scope.label} {scope.description} ))}

    {created && `Created “${created}”. Copy it now: it won’t be shown again.`}

    ) } ``` An API token form: a required name whose error grows in on submit, and a group of scopes as horizontal fields. ### Validation ```tsx 'use client' import { useState } from 'react' import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input } from '@youj/react' const TAKEN = ['youj', 'admin', 'docs', 'status'] function passwordErrors(value: string) { const errors: { message: string }[] = [] if (value.length < 12) errors.push({ message: 'Use at least 12 characters.' }) if (!/[\d\W_]/.test(value)) errors.push({ message: 'Include a number or a symbol.' }) return errors } export default function FieldValidation() { const [handle, setHandle] = useState('youj') const [password, setPassword] = useState('') const taken = TAKEN.includes(handle.trim().toLowerCase()) const problems = password ? passwordErrors(password) : [] return ( Team handle {/* Children in, message shown; undefined, message gone. It collapses over its last text. */} {taken ? `@${handle.trim()} is taken. Try another.` : undefined} Used in URLs: youj.io/@{handle.trim() || 'handle'} 0}> New password {/* Several problems become a list; duplicates collapse. */} Region Set when the team was created. ) } ``` Errors from children, from an `errors` list, or from the control's own validation, easing open and closed. FieldError shows what you pass it. Give it children for one message, or `errors` for several, in react-hook-form's `{ message }` shape: duplicates collapse, and several messages become a list. With neither, it shows the control's own validation message whenever Base UI marks the field invalid, from native constraints such as `required` or from a `validate` function, checked when `validationMode` says. A message grows the layout open. Its panel eases from zero height while the text fades in, so the fields below move down with it instead of jumping. When the error clears, the panel closes over the last message. An error that's already there when the Field first renders simply appears, as the taken handle in the preview does. ### Orientation ```tsx 'use client' import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, Input, Switch } from '@youj/react' export default function FieldOrientation() { return ( {/* horizontal: what it does on one side, the control on the other. */} Failed deploy alerts Email the team when a production deploy fails. Weekly summary A Monday digest of traffic, builds and spend. {/* responsive: stacked in a narrow group, side by side once the group is wide enough. */} Alert address A shared inbox works best. ) } ``` The same field stacked, side by side, and responsive to its group's width. `vertical`, the default, stacks label, control and description: the shape for forms. `horizontal` puts the control beside a FieldContent of label and description: the shape for settings rows. `responsive` stacks inside a narrow FieldGroup and sits side by side once the group is at least 28rem wide. It's a container query, so it follows the group's width, not the viewport's. ### Choice cards ```tsx 'use client' import { Checkbox, Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle } from '@youj/react' const FEATURES = [ { id: 'comments', title: 'Preview comments', description: 'Reviewers leave notes right on a preview deployment.', on: true }, { id: 'insights', title: 'Speed Insights', description: 'Real-user Core Web Vitals for every route.' }, { id: 'analytics', title: 'Web Analytics', description: 'Privacy-friendly traffic, with no cookie banner.', on: true }, ] export default function FieldChoices() { return (
    Enable for youj-web You can change these per project later.
    {FEATURES.map((feature) => ( // A FieldLabel around a whole Field becomes a choice card: the card is the target and carries the focus ring. {feature.title} {feature.description} ))}
    ) } ``` A whole Field wrapped in a FieldLabel: the card is the click target, and it tints when checked. Wrap a whole Field in a FieldLabel and it becomes a choice card. The entire card is the click target, it tints when its checkbox or radio is checked, and it carries the focus ring for the control inside. Use FieldTitle for the card's heading, because the FieldLabel around it already names the control. ## Installation & usage ```bash pnpm add @youj/react ``` For an app already set up with `npx @youj/cli init`, which installs the peers (`@youj/core`, `@youj/motion`, `motion` and Base UI) and wires the provider. New app? Start at [Installation](/docs/installation). Or copy the source into your app with the Youj CLI (import the package as above unless you copy in): ```bash npx @youj/cli add field ``` It brings `collapse`, `label`, `separator`, `youj-core`, `youj-presets`, `youj-ui-core` with it. Wrap form controls in Field to connect their labels, descriptions and errors for assistive technology. Group controls that answer one question with FieldSet and FieldLegend. A search or toolbar input already named by `aria-label` does not need a Field. ```tsx import { Field, FieldDescription, FieldError, FieldLabel, Input } from '@youj/react' export function WorkspaceName({ error }: { error?: string }) { return ( Workspace name {error} Shown in the sidebar and in invite emails. ) } ``` Field is Base UI's Field, so the wiring is correct by construction. FieldLabel names the control, FieldDescription and a visible FieldError describe it, and `invalid` and `disabled` reach it. `required` is Youj's addition: set it once on the Field and the label shows the mark while the control becomes required. Every Youj control works inside a Field: Input, Textarea, Input Group, Checkbox, Radio Group, Switch, Slider and Select. ## Description Each example's description is under Examples. ## Code Each example's code is under Examples. ## Reference ### Structure | Part | Renders | Use it for | | --- | --- | --- | | `Field` | `div` | One control with its label, description and error. | | `FieldLabel` | `label` | The control's name. Wrapping a whole Field makes a choice card. | | `FieldDescription` | `p` | Help text, added to the control's description. | | `FieldError` | `div` | Errors, shown in a panel that grows in and collapses out. | | `FieldContent` | `div` | The label-and-description column beside a horizontal control. | | `FieldTitle` | `div` | A label-styled heading that doesn't label anything, for choice cards. | | `FieldSet` | `fieldset` | A group of controls that answer one question. | | `FieldLegend` | `div` | The group's name, linked to its fieldset. `variant="label"` sets it at label size. | | `FieldGroup` | `div` | Stacks fields and sets. It's the container that `responsive` measures. | | `FieldSeparator` | `div` | A hairline between groups, with optional text in the middle. | Outside a Field, the label, description and error parts render as plain elements with the same look, so you can compose them freely. ### Label Outside a Field, use Label: a plain `