English
Installation
Install from npm
All public XiHan.UI packages are published to npm. They form one version group and always share the same version number; npm is the source of truth for the current one: npm view @xihan-ui/vue version.
Which packages to install depends on the adapter you use:
bash
# Vue project: adapter + default skin
pnpm add @xihan-ui/vue @xihan-ui/styles
# React project: adapter + default skin
pnpm add @xihan-ui/react @xihan-ui/styles
# Plain / non-framework project: custom elements + default skin
pnpm add @xihan-ui/web-components @xihan-ui/styles
# Design tokens only, write your own skin
pnpm add @xihan-ui/tokens
# Background layers and syntax highlighting are optional peers, install on demand
pnpm add @xihan-ui/backgrounds
pnpm add @xihan-ui/code-highlight@xihan-ui/styles is not required: components do not depend on the default skin, and writing your own styles on top of the tokens is a supported path; see "Three ways to bring in styles" below.
The adapters' engine-side dependencies (core / motion / pointer / viz / headless / position) are listed in dependencies and are installed with the adapter; you do not need to list them yourself. The peer dependency of @xihan-ui/vue is vue@^3.5.0, and of @xihan-ui/react it is react@^19.0.0 with react-dom@^19.0.0; your project provides them.
backgrounds / sound / code-highlight are optional peers; install them when you need them. Without code-highlight, the code view renders plain text instead of failing.
Besides installing from npm there are two local paths:
- Clone the repository and develop in it: build the library packages, then run the documentation site, where every example is a real component;
- Build locally and link the packages into your project: useful to follow the latest changes of the repository.
WARNING
@xihan-ui/icons only contains the in-house first-class icon set. It covers what the components and common interfaces need and does not aim to be complete. If you need a full icon set, bring your own, or plug any icon source into XhIcon: it accepts plain IconRecord data, which any source can be converted to.
Requirements
| Item | Requirement |
|---|---|
| Node (using the packages) | ≥ 18, matching the packages' engines field |
| Node / pnpm (developing in this repository) | ≥ 24.0.0 / ≥ 11.0.0 |
| Module format | ESM only, no CJS |
| Browsers | Modern browsers supporting oklch(), @layer and :where() |
Path one: clone the repository
bash
git clone https://github.com/XiHanFun/XiHan.UI.git
cd XiHan.UI/ui
pnpm install --frozen-lockfile
pnpm buildTo see the components in a browser, run the documentation site. It points at the library packages of this repository through link:, so the examples render real components, and it needs pnpm build first.
bash
cd ../docs
pnpm install
pnpm devCommon commands back in ui/:
bash
pnpm build # build every library package into dist
pnpm typecheck # type checking
pnpm lint # oxlint + eslint + stylelint
pnpm test # unit tests and cross-adapter conformance tests (jsdom)
pnpm test:browser # accessibility scans and overlay positioning contracts in real Chromium
pnpm boundaries # layer dependency gate
pnpm gate # structural gates
pnpm size # bundle size ratchetInstall the browser before the first pnpm test:browser:
bash
pnpm exec playwright install chromiumPath two: link into an existing project
Build in the XiHan.UI repository first:
bash
cd XiHan.UI/ui && pnpm buildThen point your project at it with the link: protocol (pnpm syntax; adjust the paths):
json
{
"dependencies": {
"@xihan-ui/vue": "link:../XiHan.UI/ui/packages/adapters/vue",
"@xihan-ui/styles": "link:../XiHan.UI/ui/packages/design/styles",
"@xihan-ui/tokens": "link:../XiHan.UI/ui/packages/design/tokens"
}
}@xihan-ui/vue brings in core / motion / pointer / viz / headless / position through its dependencies; they do not need to be linked separately. vue itself is its peer dependency and comes from your project.
Use it in a Vue project
ts
// main.ts
import { createVisualEnvironmentController } from "@xihan-ui/tokens/runtime";
import { createApp } from "vue";
import App from "./App.vue";
// The skin entry carries the layer order and the tokens; this one line is enough.
// Importing tokens.css alone is the path for tokens without a skin
import "@xihan-ui/styles";
// Write the eight-axis visual environment onto <html> and handle persistence failures explicitly
createVisualEnvironmentController({
root: document.documentElement,
storageKey: "app-visual-environment",
onStorageError: detail => console.error("Failed to persist visual preferences", detail),
initial: { mode: "system", motion: "system", transparency: "system" },
});
createApp(App).mount("#app");Import components from the main entry as you need them; no plugin registration is required (automatic imports and global registration are also available, see Vue adapter · Registration, in Chinese):
vue
<script setup lang="ts">
import { XhDialogContent, XhDialogRoot, XhDialogTitle, XhDialogTrigger } from "@xihan-ui/vue";
</script>The packages declare sideEffects: false, so bundlers drop unused components. There are no per-component subpath exports; importing on demand relies on tree shaking.
Built-in texts and locale
The built-in texts of components (the screen reader name of a close button, the page descriptions of a pagination and so on) are English by default. Date and time components also accept locale; without it they follow the browser language, and fall back to en-US if that cannot be read. To switch both to another language, hand a built-in language pack to the global configuration once in the root component, for example provideXhConfig(zhCN) with zhCN imported from @xihan-ui/vue/locale; texts and locale switch together, and individual phrases can still be overridden through translations.
Nuxt projects
Install the same @xihan-ui/vue and @xihan-ui/styles and add one module to nuxt.config.ts; the skin import, the visual environment controller and the component imports above are no longer needed:
ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@xihan-ui/vue/nuxt"],
});The module also renders the visual environment onto <html> on the server and reads back the reader's stored preference before the first paint. Options and how to use $xhVisual are in Vue adapter · Nuxt, in Chinese.
Use it in a plain / non-framework project
ts
import { createVisualEnvironmentController } from "@xihan-ui/tokens/runtime";
import { defineXhElements } from "@xihan-ui/web-components/define";
import "@xihan-ui/styles";
// Register every xh-* element. Importing the main entry does not register anything; call this explicitly
defineXhElements();
createVisualEnvironmentController({
root: document.documentElement,
storageKey: "app-visual-environment",
onStorageError: detail => console.error("Failed to persist visual preferences", detail),
initial: { mode: "system", motion: "system", transparency: "system" },
});Then write the tags directly in HTML. You write the structure and mark each role node with data-xh-part:
html
<xh-button variant="solid">
<button data-xh-part="root">Submit</button>
</xh-button>Three ways to bring in styles
@xihan-ui/styles is a pure CSS package, independent of the JS layer. Pick one of three granularities:
Everything: tokens + layer order + reset + every component skin. The main entry index.css is a generated flat file: the Action Control / Field Chrome / Collection Item / Swatch family recipes and the shared keyframes are inlined once, after the four common layers (focus / label / description / pointer) and before every component skin; the skins follow in source order with their own family @import removed. Apart from the one for tokens there is no @import, so no bundler can copy the families dozens of times.
ts
import "@xihan-ui/styles";Per component. layers.css and tokens.css each carry the full layer order declaration, so import either one first; component skins must not come before them. Every single skin file starts with an @import '../family/*.css' of its family, so the family is present when a skin is imported alone. The reset and the four common layers (focus ring, field label, description text, pointer feedback) are not brought in by the skins; import them yourself: without focus.css, keyboard focus only shows the browser's default outline. Projects on Vite can leave this to the plugin described below.
ts
import "@xihan-ui/styles/layers.css";
import "@xihan-ui/tokens/tokens.css";
import "@xihan-ui/styles/reset.css";
import "@xihan-ui/styles/focus.css";
import "@xihan-ui/styles/label.css";
import "@xihan-ui/styles/description.css";
import "@xihan-ui/styles/pointer.css";
import "@xihan-ui/styles/button.css";
import "@xihan-ui/styles/dialog.css";Tokens only, write your own skin.
ts
import "@xihan-ui/tokens/tokens.css";The third way keeps the layer order as well: tokens.css carries the full layer declaration, and your own skin goes straight into @layer xihan.overrides. Components do not depend on the default skin; they only write data-scope / data-part / data-state and similar attributes into the DOM, and the styling is entirely yours.
Three things to watch with the second way
- A missing import fails silently. If one skin is missing, that component's
data-scope/data-partare still there and the other skins are loaded; only it renders as a bare element without padding or background. Turn on the detector described below during development to catch it. - Keep the relative order of the skin segments in
index.css. Within the same@layer xihan.components, rules of equal specificity are decided by source order. Sorting by yourself (alphabetically, by directory order) may look the same today and render differently from the full entry once cross-component rules are added. When importing per component, filter by the order of the/* styles/xxx.css */segment markers inui/packages/design/styles/index.cssin the repository instead of sorting yourself (the published styles are minified and carry no comments or markers). - When mixing several single skins, each brings its own family
@import. Bundlers that deduplicate@imports of the same URL (Vite's default postcss path does) pay nothing; inliners that do not (such as the one bundled with@tailwindcss/vite) copy the families several times, double the output, and place later copies after earlier skins. The key override points of the families are designed around specificity rather than source order, but the copies are pure waste: switch to the main entry once you import more than a few skins.
The published style files are stripped of comments and whitespace (index.css is about 1.6 MB); the commented sources only live in the repository and are not published. Everything — all skins plus tokens — is about 180 kB gzip (about 120 kB brotli, measured on v5.1.0); the styles/index.css entry of pnpm size limits it to 197 kB gzip, and CI fails beyond that. Use the first way unless you have a clear size constraint.
Inject the skins of the components you use
On Vite, the order and the missing imports of per-component styles can be left to @xihan-ui/styles/vite. The plugin scans the source for the components you write (both export names such as XhButton and XhSelectRoot and tags such as <xh-button>) and injects the layer order, tokens, reset, common layers, family recipes and the skins of those components; components that a component borrows internally (the page size select of a pagination, the calendar of a date picker) are added too. The order is taken from the full entry, so what is injected is a subset of index.css and renders the same as the full import.
ts
// vite.config.ts
import { XiHanUIStyles } from "@xihan-ui/styles/vite";
export default defineConfig({
plugins: [vue(), XiHanUIStyles()],
});ts
// Application entry: instead of import "@xihan-ui/styles"
import "virtual:xihan-ui/styles";The plugin scans files on disk, index.html and src/ by default; scan changes the range. Components written outside that range (rendered by another package) are added with include: ["dialog"], and exclude drops unwanted ones. In development a missing-skin detector is on by default: when a component without an injected skin appears on the page, the console reports it with the fix; skinCheck: false turns it off. The injected styles are the layered variant; if the host has an unlayered reset, use the full index.unlayered.css instead. With TypeScript's noUncheckedSideEffectImports on, add a declare module for virtual:xihan-ui/styles.
Detect missing skins in development
Every component skin writes a --xh-X-skin marker on its own [data-scope='X']. startSkinCheck() probes every scope that appears on the page; a missing marker means that CSS is not loaded:
ts
if (import.meta.env.DEV) {
const { startSkinCheck } = await import("@xihan-ui/core/skin-check");
startSkinCheck();
}It reports to the diagnostics channel with the code styles.missing-skin.
Each scope is probed once (probing reads computed styles, and probing every instance would force style recalculation); later nodes are picked up with a MutationObserver, and the return value stops it. With the full import it reports nothing.
When you pass root, the element brand checks, computed styles and the MutationObserver all come from the Window that owns that root, so on-demand skins inside an iframe can be checked separately; a valid explicit root works even when the top level has no DOM globals. An explicit root without an active Window, or whose Window has no MutationObserver, throws right away rather than scanning once and silently stopping. During SSR, calling it without a root returns a no-op stop function.
The machine-readable form of the tokens can also be used directly, to generate Figma variables, a Tailwind theme or other artifacts:
ts
import tokens from "@xihan-ui/tokens/tokens.json" with { type: "json" };
// { "--xh-color-brand-500": "oklch(0.632 0.209 277)", ... }Use the unlayered build when the host has an unlayered reset
The default index.css wraps every skin in @layer xihan.*. CSS cascading has an easily overlooked rule: unlayered declarations beat any layered declaration, regardless of specificity. So as soon as the host application ships one unlayered reset or normalize rule, such as
css
button { padding: 0; background-color: transparent; }it overrides the matching declarations of every [data-scope='button'][data-part='root'] in the skin, even though the latter is more specific. Components then render as bare elements without padding or background. Tailwind v3's preflight, normalize.css and the resets shipped with most documentation sites and scaffolds are unlayered and all trigger this.
How to tell: the component's data-scope / data-part attributes are present and the skin CSS is loaded, yet none of the box model properties take effect.
For this case the package also ships index.unlayered.css with the layer wrappers removed. Its content is identical to index.css (the families are likewise inlined once, before the skins) and it is generated from the same source order:
ts
// Use this one when the host has an unlayered reset; rules then compete by specificity.
// Skin selectors are at least [data-scope][data-part] (0,2,0), safely above button (0,0,1)
import "@xihan-ui/styles/index.unlayered.css";Choosing between the two:
| Situation | Use | How to override the skin |
|---|---|---|
| Your own styles are layered too (e.g. Tailwind v4) | index.css | Write into @layer xihan.overrides, or any layer after xihan |
| The host has an unlayered reset / normalize | index.unlayered.css | Use specificity no lower than [data-scope][data-part] |
In the unlayered build, the library's own reset layer (box-sizing, font: inherit on form controls, visibility: hidden before overlays are positioned and so on) has a specificity of (0,0,0), so element selectors of the host (button { font-family: … }, (0,0,1)) beat it. This only affects the reset itself: the font-size and line-height declared by family recipes such as Action Control / Field Chrome and by the component skins are untouched; when the host reset should match the library, write the same declarations as inherit.
With index.css, the override slot is ready to use:
css
@layer xihan.overrides {
[data-scope='button'][data-part='root'] { border-radius: 0; }
}The conversion only works one way: @import url('...') layer(x) can wrap unlayered styles in a layer, but cannot take layered styles out of one, which is why the library ships the unlayered build.
This documentation site uses the unlayered build: VitePress ships an unlayered button reset, and with index.css every example would render as plain text. So the examples of this site run on the unlayered build, where the layer order does not hold. The table above, overrides through @layer xihan.overrides and anything else that depends on layer order cannot be demonstrated or verified here; verify them in your own project with index.css.
Server-side rendering
- The visual environment runtime enters an SSR branch when
document/windoware absent: it reads no media queries, writes no DOM and uses the eight-axis baseline. To avoid a flash on first paint, render the seven attributesdata-theme/data-brand/data-density/data-contrast/data-motion/data-transparency/dirdirectly onto<html>on the server. In Nuxt projects the Nuxt module (in Chinese) does this for you. - Custom elements are not upgraded before the JS arrives.
not-defined.cssin@xihan-ui/styles(formerlyundefined.css; the old path is removed in 6.0.0) covers that window: it selects thedata-xh-partnodes you wrote with:not(:defined)and collapses thecontent/positioner/backdrop/viewportof the overlay family first, so their content does not show up in the page flow as bare text that screen readers and search engines treat as body copy.
Next steps
- Quick start: minimal examples of the three ways to use a component
- Design tokens and theming: the token layers and the visual environment runtime
