use
getting started
there are two ways to use charcell. a) install it and import its components (and keep up with the package), or b) copy a component’s code into your project with add and change it as you like. both ways run on the same stylesheets and you’re free to mix (import components you like as is, copy-and-own ones you need to make adjustments to).
installing
install charcell with zag’s adapter for the framework of your choice. zag runs the components that need behavior html doesn’t provide, such as select’s listbox or a dialog’s focus trap.
pnpm add charcell @zag-js/reactpnpm add charcell @zag-js/vuepnpm add charcell @zag-js/sveltecharcell runs on react 19, vue 3.5 or svelte 5.29.
stylesheets
charcell’s styles come in parts, so you pick and swap out what you need:
charcell/css/base: the tokens (colors, font size, row height), the fonts and the modes (light and dark). styles no element on its own, but is referenced by the components.charcell/css/preflight: resets every element’s default styles and sets some global styles (background and foreground colors, font and text selection properties).charcell/css/full: base, preflight and all component styles.charcell/css: just base and all component styles.
we recommend starting with base and the preflight:
import 'charcell/css/base';
import 'charcell/css/preflight';then either a) import each component from your framework’s path (e.g. charcell/vue), which includes its styles, so a page only loads the styles it uses, or b) swap the two lines above for charcell/css/full and import each component from your framework’s unlinked path (e.g. charcell/svelte/unlinked), which includes no styles.
import { Button } from 'charcell/react';
<Button label="kick" /><script setup lang="ts">
import { Button } from 'charcell/vue';
</script>
<template>
<Button label="kick" />
</template><script lang="ts">
import { Button } from 'charcell/svelte';
</script>
<Button label="kick" />bundler setup
charcell ships its components as typescript, vue and svelte source rather than compiled javascript, so your bundler compiles them along with your own code. with vite and your framework’s plugin, that needs no setup.
rendering on the server is the exception. node can’t run charcell’s source as it is, so tell vite to bundle it rather than load it from node_modules, for example in astro:
// astro.config.mjs
export default defineConfig({
vite: { ssr: { noExternal: ['charcell'] } },
});sveltekit does this for you. next.js needs the same thing in its own terms:
// next.config.js
export default { transpilePackages: ['charcell'] };in tests under jsdom, the components built on zag need two browser apis jsdom doesn’t have:
// test setup
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
};
Element.prototype.scrollTo ??= () => {};values and open states
pass a component its value or open state and that will be its starting point (defaultValue, defaultChecked, defaultPressed, defaultOpen). bind it, and you control it:
| react | vue | svelte | |
|---|---|---|---|
TextInput, Textarea, Select, Combobox, Tabs, RadioGroup |
value and onValueChange |
v-model |
bind:value |
Checkbox |
checked and onCheckedChange |
v-model |
bind:checked |
Toggle |
pressed and onPressedChange |
v-model |
bind:pressed |
Dialog, Popover, ActionMenu, Tooltip |
open and onOpenChange |
v-model:open |
bind:open |
for example, a gang picker’s choice read by the rest of the page:
const [gang, setGang] = useState<string | number>('polecats');
<Select items={gangs} value={gang} onValueChange={setGang} /><script setup lang="ts">
const gang = ref<string | number>('polecats');
</script>
<template>
<Select :items="gangs" v-model="gang" />
</template><script lang="ts">
let gang = $state<string | number>('polecats');
</script>
<Select items={gangs} bind:value={gang} />copying components
to own a component’s code, you can copy it into your project. install charcell as a dev dependency and run the initialize command:
pnpm add -D charcell
npx charcell initializeit asks you to pick a framework, where copies go and to set an import alias. answers are written to charcell.json. a component can sit in its own folder (src/components/Button/index.tsx beside index.scss), all of them in one (src/components/ui/Button.tsx), or wherever your pattern puts them. re-run it to change your answers.
copy any components you want to own:
npx charcell add Button Selecteach component arrives with the modules, stylesheets and shared scripts it needs. imports are pointed at your copies. add lists any dependencies still needed, such as use-bem and the zag machines.
stylesheets are scss and load packages through pkg: urls, so sass needs its NodePackageImporter in your bundler’s sass options. with vite:
pnpm add -D sass// vite.config.ts
import { NodePackageImporter } from 'sass';
export default defineConfig({
css: { preprocessorOptions: { scss: { importers: [new NodePackageImporter()] } } },
});the stylesheets section still applies: load base and the preflight once, from the package (charcell/css/base, charcell/css/preflight) or from the copies add puts in your styles folder.
records of add runs are kept in .charcell/, to make future updates possible. commit it with your code. after updating charcell, run:
npx charcell status
npx charcell updatestatus lists each copy: whether you’ve edited it, and whether the new version changes it. update replaces the copies you haven’t touched and merges the new version’s changes into the ones you have, keeping your edits. git (used by update to merge) marks any conflicts for you to resolve before re-running update.
--target html copies a component as reference markup (html and classes). what html can’t express (such as conditions, slots and events) is written as comments.
styles and scope
every rule sits in a charcell.* cascade layer, and outside the preflight, none targets a bare element, so your own unlayered css takes priority over all of it. to put charcell under your own layers, name it first:
@layer charcell, base, components, utilities;charcell is white on black by default. data-charcell-mode="light" or "dark" switches it, and sets color-scheme so that scrollbars and carets follow. put it on html, since the panels of Select, Combobox, Popover, ActionMenu, Dialog and Tooltip render at the end of body, outside the component that opened them.
accessibility
charcell uses a native element wherever its look can be drawn within the constraints: a checkbox, a radio button and a toggle are real inputs, a button is a <button>, and an accordion opens on <details>. where it can’t, as with a select’s options, or where there’s no native element, as with tabs or a menu, the component runs on zag, which handles its keyboard behavior and aria. a tree follows the aria tree pattern: it’s one tab stop, and the arrow keys move through its rows and open and close its groups. a sortable list can be reordered with the arrow keys, and announces each move.
keyboard focus always shows, as ❯ in the cell before the text. no state relies on color alone: it’s inverted, marked or written out, as a field’s error prints error:. under forced colors, the inversions take the system’s highlight colors. nothing moves on its own: a scroll area steps by whole rows as you scroll it, and nothing else animates.
a Field labels its control through the control’s id. a control with no visible label takes an aria-label: a Select through triggerProps, a Combobox through inputProps. label always names a component: it’s shown where the component has a place for text, and is the accessible name alone where it hasn’t, as on the panels of a Dialog, Popover or ActionMenu. a Tree takes its own aria-label, and a Table with a caption becomes a named region you can scroll from the keyboard.
a cell row is 16px, under the 24px a pointer target asks for. where that leaves a control short of it, its docs say so and name the option that meets it: spacious on a list or a tree, two rows an item, or the boxed variant on a dense control.
content behind a control stays reachable without scripts. with scripting off, a Tabs shows every panel and a Tree every folded group’s rows. a browser that runs scripts but never gets yours, blocked or failed, can’t tell; put charcell’s bootstrap in your <head>, before your own scripts, and it marks the page when one fails to load, so the same holds there:
import { scriptFailureBootstrapScript } from 'charcell/scripts';
<script dangerouslySetInnerHTML={{ __html: scriptFailureBootstrapScript() }} />it loads nothing and marks nothing while your scripts load, and a component that did start keeps working. a content security policy that blocks inline scripts needs a nonce or a hash for it.
browsers
charcell needs chrome 125, safari 17 or firefox 128, or newer: it rounds lengths to whole cells with css round() and registers its own properties with @property.
a few things it does where the browser can, and does without elsewhere. a scroll area steps by whole rows where the browser runs scroll-driven animations, and scrolls smoothly elsewhere. a text field sizes itself to its size, and a textarea to its cols and its content, where the browser reads typed attr() and field-sizing; elsewhere a script grows the textarea, and a field given its own size keeps the browser’s width.