A framework-agnostic component library built on native Web Components (Custom Elements), with first-class wrappers for React, Vue and Angular. Components follow the Ark family naming convention (ark-* elements, Ark* types, --ark-* CSS tokens) and are styled with Tailwind CSS v4 on top of a shared design-token layer.
π Docs: Storybook (live examples) Β· npm packages Β· Changelog
π Languages: 

The monorepo is organized in layers β each package only depends on the layers below it:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Framework wrappers β
β @tooark/react Β· @tooark/vue Β· @tooark/angular β
βββββββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββ€
β @tooark/web-components β@tooark/motion β
β Native Custom Elements β opt-in β
β (ark-* components) β (Motion lib) β
ββββββββββββββββ¬βββββββββββββββββββββββββββΌββββββββββββββββ€
β @tooark/chartβ @tooark/core β@tooark/wysiwygβ
β (ECharts) β types Β· i18n Β· services β (Tiptap) β
β @tooark/code β motion presets/WAAPI β β
β (CodeMirror) β β β
ββββββββββββββββ΄βββββββββββββββββββββββββββ΄ββββββββββββββββ€
β @tooark/tokens β
β @theme tokens (--color-*, --size-*) Β· --ark-* motion β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Every package shares one version and is published together to npm under the @tooark scope.
The wrappers need @tooark/web-components next to them, and chart, wysiwyg and code need their peer dependencies: see Installation. What each package provides:
| Package | Description |
|---|---|
@tooark/tokens |
Design primitives: tokens.css, a Tailwind v4 @theme block with the semantic colors (intents) and the size scale (--color-*, --size-*, compiled to --ark-color-*/--ark-size-* in the component stylesheet), plus the motion tokens as plain custom properties (--ark-duration-*, --ark-ease-*); the Ark* primitive types (ArkRounded types Tailwind's own radius scale) and the color-scheme helpers. |
@tooark/core |
Shared foundation: TypeScript types (ArkIntent, ArkSize, β¦), i18n locales (en, pt, es), the toast and announce services, the dependency-free motion layer (CSS presets + WAAPI helpers arkEnter/arkExit) and the overlay helpers (trapFocus, openPopover/closePopover). |
@tooark/web-components |
The 41 native Custom Elements (ark-alert β¦ ark-tooltip, listed under Components). |
@tooark/react |
React wrappers with typed props. |
@tooark/vue |
Vue 3 wrappers. |
@tooark/angular |
Angular wrapper components. |
@tooark/chart |
ark-chart β charts built on ECharts (peer dependency). |
@tooark/wysiwyg |
ark-wysiwyg-editor / ark-wysiwyg-viewer β rich-text editor and viewer built on Tiptap. |
@tooark/code |
ark-code-editor β code editor built on CodeMirror 6 (peer dependencies): JSON, JavaScript and YAML, variable completions. |
@tooark/motion |
Opt-in advanced animation helpers built on Motion: staggered list entrances, scroll reveal, FLIP reordering and swipe gestures with spring physics. |
| Element | Package | Highlights |
|---|---|---|
ark-alert |
web-components | Alert/banner: the host is the box in the intent's soft color, your children are the message (free text or elements), slot="icon" left, slot="action" right, heading, dismissible with animated exit, live region (status/alert). |
ark-avatar |
web-components | Avatar (role="img" named by name): initials from name, src image that falls back to the initials on load error, size, shape circle/square, custom color, variant soft/solid. |
ark-badge |
web-components | Short status/category label: intents, soft/solid/outline, xsβmd, rounded, custom color via color-mix. The host is the badge; icon and text stay as its children. |
ark-button |
web-components | Intents, sizes, style variants (solid/outline/ghost), rounded (up to full), loading/icon-only/full-width states, status feedback (success/error glyph + announcement), link mode (href). |
ark-calendar |
web-components | Inline month grid (MUI DateCalendar-style): localized, WAI-ARIA keyboard navigation, motion, month/year views from the title and colored events (dots/count/list). |
ark-card |
web-components | Card: the host is the box (surface, border, rounded) and a grid: heading (h2) or slot="header" with slot="actions" on the top row, unslotted children as the body, slot="footer" last with a divider; padding noneβlg. |
ark-carousel |
web-components | Native CSS scroll snap (touch/trackpad scroll natively, mouse drag emulated), autoplay, loop, dots and arrows. Slides stay as your direct children. |
ark-checkbox |
web-components | Checkbox drawn by the component (role="checkbox" button + hidden native input for forms and <fieldset disabled>): checked, indeterminate, label/aria-label/free children as the label, helper hint below it, sizes, intents. Emits change. |
ark-clock |
web-components | Time selection with scrollable digital columns (hours/minutes/seconds), 24h/12h, minute step, localized. |
ark-color-swatches |
web-components | Color palette as a radiogroup: swatches from colors ({ name, value }[], JSON attribute or JS property), value, arrows navigate, disabled, size. Emits change with the value. |
ark-command-item |
web-components | Command palette item (the host is the role="option"): free children, slot="trailing" for a shortcut, value, group, label (filter text), disabled. Emits ark-select. |
ark-command-palette |
web-components | Command palette on the Popover API: search field (an ark-input of its own), ark-command-item children grouped and filtered (filter) or searched by the app (ark-query), arrows + Enter, hotkey (/, mod+k), hint and busy for the empty message. Emits ark-select, ark-query, ark-open, ark-close. |
ark-copy-button |
web-components | Copy button: extends ark-button (same variants, sizes, icon-only, forms, hooks); copies value or the for element, swaps the icon for a check and the text/title for "copied" for feedback-ms, announces it. Emits ark-copy. |
ark-datepicker |
web-components | Date/time picker composing ark-input + ark-calendar + ark-clock: mode datetime (default)/date/time, inline or input mode with field + popup, localized formatting, typed input parsing, forms. |
ark-dialog |
web-components | Modal dialog on the Popover API (top layer, ::backdrop scrim, no portal): the host is the panel, your children are the body, slot="footer" is the footer; header from label, focus trap, Esc/scrim, smβxl or full. |
ark-drawer |
web-components | Drawer anchored to an edge: overlay (Popover API, scrim, focus trap, Esc) or inline (in the page flow, e.g. a bottom console); side, size preset or CSS length, header from label with a slot="actions" row, slot="footer", slide with the sheet easing. Emits ark-open/ark-close. |
ark-empty |
web-components | Empty state: dashed box with a dimmed slot="icon", heading (h3), description and an optional slot="action" button; children stay in place, ordered by CSS. |
ark-file-input |
web-components | File field with the ark-input grid (label, helper/error): hidden native <input type="file"> for forms, drop zone with dragover highlight, keyboard-accessible choose button, list of selected names, accept/multiple, directory for a whole folder. Emits change with the files and announces them. |
ark-input |
web-components | Standardized text field: label, helper/error with aria, prefix/suffix via slot, password reveal, native attributes passed through, sizes, intents, rounded. |
ark-kbd |
web-components | Keyboard key: the host is the key (mono, border, surface-muted, bottom edge) around your text; size. |
ark-kv-editor |
web-components | Key/value editor: rows { id, key, value, enabled } (JS property) to enable, edit, delete and add, optional types/secret/description columns, bulk mode as key:value lines or JSON, opt-in valueField for a value cell of your own (variable autocomplete, for instance). Composes other ark-* controls. Emits change, ark-add, ark-delete. |
ark-mark |
web-components | Scope mark: one of ten shapes (circle, square, triangle, diamond, star, hexagon, cross, pentagon, moon, asterisk) in a color, color and shape together so identity never relies on color alone; size, label. |
ark-menu |
web-components | Dropdown/context menu on the Popover API (role="menu", popover="auto"): anchored to a trigger by for, align/direction with flip, keyboard, openAt(x, y); items stay as children. Emits ark-select. |
ark-menu-item |
web-components | Menu item (the host is the item): free children, slot="trailing", disabled, intent, checked (checkbox item), divider, static (non-interactive content). |
ark-progress |
web-components | Progress bar (role="progressbar" on the host): value/max with token-driven width transition, show-value, indeterminate loop exempt from reduced motion, label, sizes, intents. |
ark-radio |
web-components | Radio drawn by the component (role="radio" button + hidden native input): groups by name in the same form, one tab stop per group, arrows move and check, label/children as the label, helper hint. Emits change on the one checked. |
ark-scheduler |
web-components | Scheduler with week/day views (time grid with overlap resolved into columns), plus month and agenda; colored, clickable events. |
ark-select |
web-components | Native <select> styled like ark-input: label, helper/error with aria, placeholder, options from data (options JSON attribute or JS property, group β <optgroup>), sizes, intents, rounded. |
ark-shape-picker |
web-components | Shape picker as a radiogroup: the ten ark-mark shapes, or the ones shapes lists, drawn in color, value, arrows navigate, localized shape names, disabled, size. Emits change with the shape. |
ark-skeleton |
web-components | Loading placeholder: the host is the block (.ark-skeleton, aria-hidden), sized by your class/style; rows renders bars, animated opts into the shimmer, rounded. |
ark-spinner |
web-components | Standalone loading indicator (role="status"): the button's spinner SVG on .ark-animate-spin (keeps spinning under reduced motion), screen-reader label by lang or label, size, optional intent (inherits the text color otherwise). |
ark-split-pane |
web-components | Resizable panels: your children are the panels, the handles are the component's own nodes at the end of the host; direction, sizes (percentages, rewritten on every change), data-min/data-max per panel, keyboard and pointer-capture drag. Emits ark-resize. |
ark-status-dot |
web-components | Status dot: the host is the circle in the intent color (default neutral); label makes it a named role="img", without it it is decorative; size. Static, never pulses. |
ark-switch |
web-components | Accessible on/off switch (role="switch"): optional ON/OFF text and β/β icons, intents, form participation via hidden checkbox. |
ark-tab |
web-components | Tab item (role="tab", the host is the control): free children, disabled, controls, closable and dirty in the editor variant. Emits ark-close. |
ark-tabs |
web-components | Tab strip (role="tablist"): underline/chips/editor, roving tabindex with arrows/Home/End, slot="actions" at the end, change with the active value. Panels stay with the app. |
ark-textarea |
web-components | Multi-line field with the ark-input grid: rows, autosize (native field-sizing, JS fallback), monospace, resize, helper/error with aria, sizes, intents, rounded. |
ark-toaster |
web-components | Sonner-style toasts: programmatic API, positions, rich colors, actions, animated enter/exit, localized close button. |
ark-toggle |
web-components | Pressed-state button (aria-pressed), standalone (outline/tinted per intent) or as a group item. |
ark-toggle-group |
web-components | Segmented control: exclusive (default) or multiple selection, synced value, propagates size/intent/theme/disabled to items. |
ark-tooltip |
web-components | Tooltip on the Popover API: wraps your trigger without moving it, text via content or rich slot="content", side with flip, delay, hover/focus/Esc, aria-describedby on the trigger. |
ark-chart |
chart | ECharts-powered chart types with theme support; aria-label names the chart as one image. |
ark-wysiwyg-editor |
wysiwyg | Tiptap-based rich-text editor: JSON content sanitized on the way in, opt-in toolbar groups (style, marks, color, align, lists, link, media, blocks, clear, history), external images/videos through an uploadFile hook (never base64). |
ark-wysiwyg-viewer |
wysiwyg | Read-only viewer sharing the editor's schema: the same sanitized JSON content, links opened in a new tab. |
ark-code-editor |
code | CodeMirror 6 editor: language json/javascript/yaml/text, line numbers, folding, search, Tab indentation, LF/CRLF, readonly, wrap, placeholder, completions (variableKeys after {{, completions, completionSource), format(), token theme (auto follows the page), opt-in scoped variables and single-line field. Emits change, ark-submit, ark-format-error. |
ark-alert β the host is the box (intent soft background and border, default info; variant box, default, rounded, or banner, full width with a bottom border only) and your children are the message: free text or elements, flowing normally. slot="icon" sits on the left and slot="action" (your buttons) on the right of the first line, positioned by components.css without moving them (the icon and the dismiss button in space reserved by the padding, the action floated so long text wraps around it). heading renders the component's own title at the start of the host. dismissible adds a dismiss button at the end (labelled by lang/locale-json, key dismiss); dismissing runs the exit (slide-down, quick), emits ark-dismiss and sets hidden on the host: removing it is up to the app, and removing hidden shows it again. live picks the live region: polite (role="status"), assertive (role="alert") or off (no role); by default warning and danger are assertive and the rest polite. theme, testid. JS: dismiss(), dismissible.
ark-avatar β the host is the avatar (role="img" with aria-label from name; without name, and without your own aria-label, it is aria-hidden and shows a person glyph). name yields the initials: the first letter, or first plus last when there is a surname ("Ana Lima" β AL, "Ana" β A). src renders an <img> (alt equal to name, object-cover) and falls back to the initials when the image fails to load. size (xsβxl: 1.5 to 4 rem), shape (circle, default / square, rounded corners), color (any CSS color: initials in that color on a soft color-mix background instead of the primary tint), variant (soft, default, as above / solid: the background filled with primary and the initials in primary-fg, or with color and white initials), theme, testid. JS: initials (read-only).
ark-badge β the host itself is the badge (inline-flex); put an icon and text as its children. intent (default neutral), variant (soft, default / solid / outline), size (xs/sm/md; the height comes from font and padding, not from the control size token), rounded (default full), color (any CSS color instead of the intent: text in that color, soft background or outline via color-mix, white text on solid), theme. Not interactive: a clickable badge is a small ark-button.
ark-button β the host element itself is the control (role="button", focus, keyboard, form participation via ElementInternals), so aria-label, class and id on <ark-button> apply directly and its children are never moved. variant (solid/outline/ghost or any intent, neutral included), intent, size (xsβxl), rounded (none/xs/sm/md/lg/xl/full β combined with icon-only, full yields a circular button), loading (spinner + aria-busy + blocked clicks), icon-only (square: min-width equals the size token), full-width, href/target (a stretched <a> covers the host, takes the focus and is named by the host content; _blank gets rel="noopener noreferrer"), disabled, type (button, default / submit / reset), theme, color/text-color. status (idle, default / success / error) swaps the leading glyph for a check or an alert circle with .ark-animate-fade-in; loading wins over it; the app clears it, the button only shows it. When status becomes success or error, the button announces status-label to screen readers through the core announce() service (polite for success, assertive for error), so no live region ever enters the button's accessible name; without status-label nothing is announced. JS: status, statusLabel, disabled, loading, type, form.
ark-calendar β value (YYYY-MM-DD, parsed in the LOCAL timezone), min/max, lang (en/pt/es/custom + locale-json), theme, intent, accent-color. Clickable title cycles days β months β years. Events: events attribute (JSON) or JS events property with { date, label?, color?, intent? }, rendered per event-display (dots default, count, list). Emits ark-change with detail: { value, date, events }. The footer has Today and Clear buttons (labelled by lang): Today selects the current day, Clear empties the value and emits ark-change with value and date null and events []. Keyboard navigation: arrows move between days (crossing months), Home/End jump to month start/end, PageUp/PageDown switch months.
ark-checkbox β the visual box is a <button role="checkbox"> drawn by the component and a hidden native <input type="checkbox"> carries name/value to the form (submitted only when checked; value defaults to on); both are native controls, so <fieldset disabled> disables them with no attribute on the host, and the host dims box and label together. checked, indeterminate (aria-checked="mixed" with a dash; the next click clears it and toggles checked), disabled, name, value, size (xsβxl, box on the spacing scale), intent, theme, testid. Accessible name: label renders the component's own <label for> (clicking it toggles); without it put aria-label on the host (a box alone in a table row), or use free children as the label (text, links) β they name the box through aria-labelledby and clicking them toggles, while a link or control inside them keeps its own behavior. A <label> of your own wrapped around the element works too (the drawn button is the first labelable control inside it, so the label names the box and a click on its text toggles once), which lets a raw <input type="checkbox"> inside a <label> be swapped for the element as is; a <label for> pointing at the host does not, since the host is not a form control. helper adds a hint below the label (the host becomes a grid: the box, then the label and the hint stacked) that describes the box through aria-describedby; when free children name the box, the hint is aria-hidden so it stays out of the name and is still read as the description, and the free label should then be one element or one text. Space toggles, Enter does not. Emits change with detail: { checked }. JS: checked, indeterminate, disabled, name, value, toggle(), focus().
ark-clock β value (reads HH:mm[:ss], always 24h; a pick writes it back as HH:mm:ss, with :00 when there is no seconds column), seconds (seconds column), step-minutes, hours-format (24 default or 12 with an AM/PM column), lang (en/pt/es/custom + locale-json), theme, intent. Emits ark-change with detail: { value }, always HH:mm:ss.
ark-file-input β the ark-input grid (label, helper, error (boolean, the error state without a message) / error-message with aria-invalid/aria-describedby) around a drop zone: a dashed box with the component's own choose button (chooseFile), the drop hint (dropHint) and the list of selected file names (or noFile). A hidden native <input type="file"> (hidden, tabindex="-1") carries name, accept, multiple, required and disabled to the form, so <fieldset disabled> and FormData work as usual; the button opens the native picker and drag and drop onto the zone (highlighted with data-ark-dragover) sets the same input (only the first file without multiple; accept is enforced by the picker, not by the drop). A dropped folder is not a file (the browser hands it over as an empty File that cannot be read), so it is left out: the files dropped with it are kept, the list shows foldersNotAccepted and the announcement carries it, and a drop of folders alone changes nothing and emits no change. On every change it emits change with detail: { files } (an array of File) and announces the chosen names, or noFile, through core's announce(). directory (opt-in) turns the field into a folder picker: the hidden input gets webkitdirectory (plus multiple, so a browser without folder selection, as older mobile ones, falls back to choosing the folder's files), the button and the hint read chooseFolder/dropFolderHint, and a folder dropped on the zone is read to the end, subfolders included (loose files dropped with it are kept under their own name). The list becomes one summary line (the folder name when there is a single one, fileCount and the total size; noFolder when empty) and so does the announcement, and change carries detail: { files, paths }, where paths[i] is the path of files[i] from the folder name down (collection/auth/login.bru); accept does not filter a folder's content, and a form submits a dropped folder's files under their plain names, so read paths when the structure matters. size (button height by the --ark-size-* token and text sizes), intent (focus, button and drag highlight), rounded (zone corners, default lg), theme, lang/locale-json, testid. JS: files (array), paths (array, the path of each file), multiple, directory, disabled, clear(), focus(), inputElement.
ark-input β type (text, password, email, number, tel, url, search, date, time, datetime-local), label (becomes a real <label for>), placeholder, value, name, size, intent, theme, rounded, helper, error/error-message (with aria-invalid/aria-describedby), disabled, required, readonly. Prefix and suffix via children with slot="prefix"/slot="suffix", positioned over the field's edges without being moved. reveal on a type="password" field adds a show/hide button (aria-pressed, labelled by lang/locale-json) that toggles the type; it coexists with a user suffix. Native attributes are mirrored onto the inner <input> as-is: autocomplete, autofocus, inputmode, maxlength, minlength, pattern, min, max, step, spellcheck, aria-label. For composition: focus() and the inputElement getter.
ark-color-swatches β the host is the role="radiogroup" (named by label or your aria-label) and every swatch is the component's own role="radio" button filled with the color, named by the swatch name (aria-label) and aria-checked when it is the value. colors is { name, value }[] as a JSON attribute or as the JS property; value is the selected color (the swatch value string). Keyboard follows the native radio: one tab stop (the selected swatch, else the first), arrows move and select with wrap, Home/End jump to the first and last, Space or Enter selects. disabled, size (xsβxl: 1 to 2.5 rem swatches), theme, testid. Emits change with detail: { value } only when the selection changes. JS: value, colors, disabled, select(value) (selects as the user would, emitting change).
ark-command-item β the host is the option (role="option", never focused: the search field keeps focus and points at it with aria-activedescendant); icon and text are free children and a slot="trailing" child (an ark-kbd shortcut, a badge) goes to the right by CSS. value is what ark-select carries, group names the section it is listed under, label is the text used for filtering and for the option name (default: the text content), disabled keeps it visible but unselectable. The palette sets id, hidden, aria-selected and the visual order; you own everything else. testid.
ark-command-palette β the host is the panel (popover="manual", role="dialog" named by label, scrim, focus trap, Esc, top-centered like a Spotlight), your ark-command-item children stay where they are, and the component adds its own chrome at the ends: the search field (an ark-input created by the component, role="combobox", sticky at the top) and, at the end, one header per group plus the noResults message, all placed by CSS order so nothing is inserted between your items; a hidden role="listbox" owns the visible options through aria-owns, and the field points at the active one with aria-activedescendant. Typing filters locally by each item's label when filter is set (case and accent insensitive) and always emits ark-query with detail: { query } debounced by query-delay (default 150 ms), so an app can replace the children with async results. With no visible option the palette shows the noResults string; hint replaces it while the field is empty ("Type to searchβ¦" on a palette that starts empty), and busy (JS busy) marks a search in progress: the message becomes the searching string and the hidden listbox gets aria-busy. Set busy on input or ark-query and clear it when the results arrive. Keyboard: arrows move the active option (wrapping, skipping disabled), Home/End, Enter selects, Esc closes; hover also activates. hotkey toggles it from anywhere on the page (/, mod+k, ctrl+shift+p...; mod is Ctrl or Cmd): it opens the palette and, pressed while it is open, closes it; while closed it is ignored when the focus is in an input, textarea, select or contenteditable. open is the source of truth (show(), close()); placeholder (default the search string), no-scroll-lock (the page stops scrolling while open unless set), theme, lang/locale-json, testid; override the scrim color with --ark-command-palette-scrim (falls back to --ark-dialog-scrim). JS: query (the search text), queryDelay, noScrollLock. Emits ark-select with detail: { value } (then closes), ark-query, ark-open, ark-close.
ark-copy-button β ArkCopyButton extends ArkButton: the host is the button itself, with every ark-button attribute that makes sense here (variant, intent, size, rounded, icon-only, full-width, loading, disabled, color/text-color, theme, testid, form participation, keyboard), so your children (label, icon) stay where they are and nothing is nested. What to copy: value (the text) or for (the id of an element: its value when it has a string value β inputs, textareas, selects, ark-* fields β, its textContent otherwise). The component adds its own copy icon at the start and, when you give no children, its own label with the copy string; on click it writes to the clipboard (navigator.clipboard.writeText, falling back to execCommand("copy") on insecure contexts), swaps the icon for a check, the label and title for copied (and aria-label too with icon-only), announces copied through core's announce(), sets data-ark-copied on the host and reverts after feedback-ms (default 1500). With your own children only the icon, title and the announcement change. lang/locale-json (keys copy, copied). Emits ark-copy with detail: { value }. JS: copy() (async, resolves true when it copied), value, feedbackMs.
ark-datepicker β composes ark-input + ark-calendar + ark-clock. mode: datetime (default, value YYYY-MM-DDTHH:mm:ss), date (YYYY-MM-DD) or time (HH:mm:ss). Without input it renders the panels inline; with input, field + popup: the popup is the component's own popover="manual" (top layer, so no overflow in your layout clips it and there is no z-index), anchored to the field by positionAnchored (opens below, flips above when there is no room, follows scroll and resize) and animated with scale from that origin. format with YYYY/MM/DD/HH/mm/ss tokens (case-sensitive; the default follows mode and seconds: the date part is MM/DD/YYYY for en and DD/MM/YYYY otherwise, the time part HH:mm, or HH:mm:ss with seconds, and datetime joins both), placeholder, seconds, name (form submission with the ISO value via a hidden input, inline or with input), disabled (blocks the field and the panels in both modes). It forwards lang, locale-json, theme, intent, accent-color, min, max, events and event-display to the calendar and lang, locale-json, theme, intent, seconds, step-minutes and hours-format to the clock. Emits ark-change with detail: { value, date } (value is null when cleared; the panels' own ark-change is consumed). Typed input is validated (invalid entries revert); in date mode selecting closes the popup, in datetime it stays open to pick the time; Esc/outside click close.
ark-dialog β the host is the panel, opened as popover="manual" (top layer, ::backdrop as the scrim, no portal and no z-index); your children are the body and stay in place, and a child with slot="footer" becomes the footer (actions aligned to the end, sticky at the bottom of the scrolling panel, or at the bottom of the panel when it is taller than its content) by CSS only. The component adds the header (an h2 from label plus the close button) as its first child, so reading starts at the title. open (reflected, the source of truth: show(), close(reason) and the open property only toggle it, so a framework removing the attribute still gets the exit animation), size (sm / md, default / lg / xl / full, which fills the viewport without corners), width/height (a CSS length, a bare number meaning px; each overrides the preset on its axis and stays capped by the viewport margin; without height the panel is as tall as its content), label (title and accessible name via aria-labelledby; without it give the host an aria-label, otherwise the component warns once), no-close-button, persistent (Esc and the scrim do not close), no-scroll-lock (by default the page stops scrolling while the dialog is open, see lockScroll in the overlay helpers), theme, lang/locale-json (close label), testid; JS: persistent, noScrollLock. Focus goes to the first child with autofocus, else the first focusable in the body, else the close button, else the panel, and returns to the opener on close. Emits ark-open and ark-close with detail: { reason: "escape" | "backdrop" | "close-button" | "api" } when the close starts. Motion: scale in, fade out (run before leaving the top layer), scrim by opacity; override the scrim color with --ark-dialog-scrim. Limit: the Popover API does not make the rest of the page inert. The trap keeps keyboard focus inside and cancels pointer events that start outside the panel (popovers opened on top, such as a menu or the toaster, stay usable), but a screen reader browsing with its virtual cursor can still reach the content behind the dialog.
ark-drawer β the host is the panel and your children are its body; a slot="footer" child becomes the sticky footer and label renders the header (h2 + close button, first child) exactly like ark-dialog. A slot="actions" container (a <div> with a console's filters and buttons, or an edit button) joins the header row between the title and the close button, centered on the title line and sticky with the header; the title reserves the actions' width, measured with a ResizeObserver, and the header exists even without label. mode="overlay" (default) opens it as popover="manual": top layer, ::backdrop scrim (--ark-drawer-scrim), role="dialog" with aria-modal, the core focus trap, Esc and scrim clicks close (unless persistent), the page stops scrolling (unless no-scroll-lock), and the panel slides in from its edge across its whole width or height. mode="inline" keeps the host in the page flow as a role="region" (the bottom console, a side panel): no popover, no scrim, no trap, Esc does nothing; open toggles it with a short slide. side (left/right/top, default right, bottom) picks the edge, the slide direction and the drawn border; size is sm/md/lg (18/24/32 rem wide, 16/20/28 rem tall) or any CSS length on the drawer's axis (a number is px; 100% is a full-width mobile sidebar). Enter uses the sheet easing (cubic-bezier(0.32, 0.72, 0, 1), a new motion token) on the default duration, exit in on quick. open is the source of truth (show(), close(reason), tolerant open setter); no-close-button, theme, lang/locale-json, testid; JS: side, mode, persistent, noScrollLock. Emits ark-open and ark-close with detail: { reason } (escape, backdrop, close-button, api).
ark-empty β the host is the dashed box; your children stay in place: a child with slot="icon" (dimmed, an svg inside is sized to 2.5rem) and one with slot="action" (an ark-button, or several). heading (an h3) and description (a p) are created by the component. The visual order is icon, heading, description, any other child, action, whatever the DOM order. theme, testid. Not interactive and without a role of its own.
ark-kbd β the host is the key cap (monospace, border, surface-muted background and a bottom edge) and your children are the key text (Ctrl, K, β); compose shortcuts with several of them. size (xsβxl: minimum height 1 to 2 rem on the spacing scale), theme, testid. Not interactive and no role: screen readers read it as plain text, like a <kbd>.
ark-kv-editor β the one component that composes others (ark-checkbox, ark-input, ark-select, ark-textarea, ark-button), because it renders everything from data: set the rows JS property ({ id, key, value, enabled, type?, secret?, description? }[]; a rows JSON attribute seeds it) and the component draws a toolbar (count, bulk toggle), one row per entry (enable checkbox, key and value fields, delete button) and an add button; disabled rows stay in the list, dimmed. Three optional columns: types="string,number,date" adds a select with the value type per row (the row's type, defaulting to the first type) and the value field follows it (number, date, time, datetime, email, url and tel become that input type, anything else is text); secret adds a padlock per row (aria-pressed, the secret string): closed, the value becomes a password field with the reveal eye of ark-input, open, it goes back to the row type; description adds a description field (description-placeholder). Every edit emits change with detail: { rows } (a copy); adding emits ark-add with detail: { id }, focuses the new key and announces the count through core's announce(); deleting emits ark-delete with detail: { id }, moves focus to the next row and announces too. Enter in the last row's value adds a row. bulk (attribute/property, also the toolbar toggle) swaps the table for a monospace textarea in bulk-format: lines (default) is key:value per line with # in front of disabled rows, quick for headers, and keeps each row's type, secret and description by position; json is an array with every field but id, for the richer columns. Edits are parsed as you type (invalid JSON marks the field and leaves the rows alone) and change fires again when you leave the mode, reusing the ids of when it opened, by position. key-placeholder/value-placeholder (also the fields' accessible names), readonly, size (of the composed controls), theme, lang/locale-json (add, bulkEdit, tableEdit, entries, noEntries, deleteRow, type, secret), testid. Your own value cell (opt-in): set the valueField JS property to a function ({ row, type, size }) => HTMLElement | null and the element it returns replaces the row's value ark-input (a single-line ark-code-editor with variables gives {{variable}} autocomplete, for instance). The element exposes value (text) and emits input or change on each edit; the editor writes value, placeholder, aria-label, size and readonly on it, marks it data-ark="kv-editor-value" and adds a row on an Enter it did not cancel, or on an ark-submit, from the last row. Secret rows keep the password field (the function is not called for them), returning null keeps the ark-input in that row (a number row, say), and a row whose type or padlock changes gets a new cell. Keep the function stable (a new function rebuilds every cell); without valueField nothing changes. JS: rows, bulk, bulkFormat, bulkText, types, secret, description, readonly, valueField, add(row?), delete(id).
ark-mark β the host is the mark: an inline SVG (viewBox 0 0 24 24, one path per shape) drawn in color (any CSS color; default the surrounding text color) with shape circle (default), square, triangle, diamond, star, hexagon, cross, pentagon, moon or asterisk, so a workspace or environment is told apart by color and shape at once. size is the SVG side in px (a number) or any CSS length (default 16). label makes it a role="img" with that name; without it the mark is aria-hidden, for the usual case where the text beside it names the thing. theme, testid. JS: shape. ark-color-swatches and ark-shape-picker are the matching pickers.
ark-menu β the host is the panel (role="menu"), opened as popover="auto" (top layer, native light dismiss and Esc, no portal and no z-index); your ark-menu-item children stay in place. Wire a trigger with for="<id>": the component only writes aria-haspopup, aria-expanded and aria-controls on it (and an id when missing), names the menu after it with aria-labelledby, toggles on click and opens with ArrowDown/ArrowUp; the trigger may mount after the menu (resolved on connect and watched until found). align (start, default / end), direction (down, default / up; flips when there is no room), open (reflected from the popover state), size and theme (propagated to the items), testid. JS: show(), hide(), openAt(x, y) for a context menu at a point (the menu switches to popover="manual" while open at a point and dismisses on outside pointerdown itself). Inside: arrows with wrap skipping disabled, divider and static items, Home/End, Enter/Space select, Esc closes and returns focus to the trigger, Tab closes. Emits ark-select with detail: { value } (the item's own event is consumed; the menu closes right after), ark-open and ark-close. Positioned by JS from getBoundingClientRect() (positionAnchored, see the overlay helpers); enter/exit are CSS transitions (scale plus fade from the origin corner, both in and out), so light dismiss animates too.
ark-menu-item β the host is the item (role="menuitem", focus, keyboard); put an icon and text as children and a shortcut or badge in a child with slot="trailing". value, disabled, intent (danger for destructive actions; any intent colors text and hover), checked (turns it into a menuitemcheckbox with aria-checked and a check at the end; checked="false" is an unchecked one), divider (role="separator"), static (non-interactive content such as the user's name and e-mail; role="presentation", outside the arrow cycle; it inherits the muted text color, so color your own children with the tokens, e.g. var(--ark-color-fg) for the name). JS: value, disabled, checked (three states: true/false make a checked/unchecked checkbox item, null/undefined a plain item), select().
ark-progress β the host is the role="progressbar" (aria-valuenow/aria-valuemin/aria-valuemax, name from label or your own aria-label/aria-labelledby): a row with the track (bg-muted, height by size xsβxl: 0.25 to 1 rem) and, with show-value, the rounded percentage beside it. value (0 to max, clamped; max defaults to 100) sets the bar width with a transition on the default duration and out easing tokens (instant under reduced motion). indeterminate removes aria-valuenow, hides the value and swaps the bar for a 40% segment sweeping the track in a loop; like the spinner it is a continuous loader, so it runs on a fixed duration and keeps moving under prefers-reduced-motion (it is the only sign of progress). intent colors the bar. theme, testid. JS: value, max, indeterminate, showValue.
ark-radio β same construction as ark-checkbox (a <button role="radio"> and a hidden native <input type="radio"> that groups by name in the form; <fieldset disabled> works). The group is every ark-radio with the same name inside the same <form>, or in the same root when there is no form: checking one unchecks the others silently (also when you set checked from JS or the attribute), and change fires only on the one that gained the check, with detail: { value }; clicking the checked one emits nothing. Keyboard follows the native radio: a single tab stop per group (the checked option, else the first enabled one), arrows (all four) move focus and check, wrapping and skipping disabled options, Space checks the focused one, Enter does nothing. Put the options in a container with role="radiogroup" and a label. checked, disabled, name, value (default on), label/aria-label/free children/a wrapping <label> and helper as in ark-checkbox, size, intent, theme, testid. JS: checked, disabled, name, value, select(), focus().
ark-scheduler β view (week default, day, month, agenda), date (reference date, kept in sync while navigating), events (JSON attribute or JS property) with { id?, title, start, end?, allDay?, location?, color?, intent? }, views (limits the switcher, e.g. "day,week"), hour-start/hour-end, slot-minutes (15β60), hours-format, lang/locale-json, theme, intent. JS: events, view, date. Emits ark-event-click ({ event, id }), ark-slot-click ({ start, end, allDay } β clicking an empty slot or a day), ark-view-change ({ view }) and ark-range-change ({ start, end, view }). Time views position events by time, resolve overlaps into side-by-side columns and mark the current time.
ark-select β a native <select> with the ark-input grid (label, field, message) and a chevron drawn by the component, so keyboard, screen reader and mobile pickers come for free. Options come from data, never from children: the options attribute (JSON) or JS property ({ value, label, disabled?, group? }[]; group renders an <optgroup>). label, placeholder (a disabled, hidden empty option shown until something is chosen), value, name, size (xsβxl), intent, rounded, helper, error/error-message, disabled, required, theme; aria-label on the host is mirrored onto the <select> for a field without a visible label. JS: selectElement, value, options, focus(). Emits change with detail: { value } (the native change does not bubble a second time) and input.
ark-shape-picker β the host is the role="radiogroup" (named by label or your aria-label) with one role="radio" option per shape, rendered by the component (by default the ten ark-mark shapes; shapes picks which and in what order, comma-separated as in shapes="circle,moon,cross", with unknown and repeated names dropped), each an ark-mark drawn in color (the current scope color, so the user sees the real pair) and named by the localized shape name (lang/locale-json, keys shapeCircleβ¦shapeHexagon, shapeCross, shapePentagon, shapeMoon, shapeAsterisk). value is the selected shape (any of the ten; one that is not offered leaves no option checked). Same keyboard as ark-color-swatches (one tab stop, arrows move and select, Home/End, Space or Enter). disabled, size (xsβxl), theme, testid. Emits change with detail: { value }. JS: value, shapes, disabled, select(value) (an offered shape only).
ark-skeleton β the host is the block (core's .ark-skeleton: soft muted background and radius; aria-hidden since there is nothing to read). Width and height come from your class or style (default height 1rem). rows above 1 replaces the block with that many bars in a column, the last one shorter; animated opts into the shimmer (.ark-skeleton-animated: a highlight sweeping across, phase-shifted per block and per bar so nothing blinks in sync; static by default and stopped under prefers-reduced-motion); rounded (noneβfull, default the preset's lg), color (any CSS color as the base tint instead of muted, for skeletons on colored surfaces), ratio (16/9, 9/16, 1/1, 16:9 or a number: an image placeholder whose height follows the width; ratio="1/1" rounded="full" is an avatar; ignored with rows), theme, testid. There is no reveal element: give the content that replaces the skeleton .ark-animate-fade-in. JS: rows, animated.
ark-spinner β the host is the role="status", with the same SVG as the ark-button spinner (the only spinner in the lib) turning on core's .ark-animate-spin, which keeps running under prefers-reduced-motion because it is the only sign of progress. size (xsβxl: 0.75 to 2 rem), intent (color token; without it the spinner inherits the surrounding text color, so it fits inline text or a colored button), label (screen-reader-only text; default the loading string of lang/locale-json), theme, testid.
ark-split-pane β the host is a CSS grid whose tracks come from sizes (panel, handle, panelβ¦); your children are the panels and stay where they are, auto-placed into the panel tracks in DOM order (the component never touches them; give each its own overflow), while the handles are the component's own nodes appended at the end of the host and placed by explicit grid-column/grid-row. direction (horizontal, default, side by side / vertical, stacked); sizes is a comma list of percentages in child order (missing ones share what is left, the list is normalized to 100) and is the source of truth: every drag or key rewrites it. data-min/data-max on a child limit it, as a percentage (20, 20%) or in pixels (200px). Each handle is a focusable role="separator" (named by the resize string of lang/locale-json, aria-orientation, aria-valuenow/min/max for the panel before it): arrows along the axis move 2 points (10 with Shift), Home/End go to the limits, drag uses pointer capture (data-ark-dragging on the host disables pointer events inside the panels meanwhile); the handle track shows a 1px divider with a grip in the middle that turns primary on hover, focus and drag. A split pane divides along one axis only: for a tree beside an editor stacked over a response, put a vertical ark-split-pane as the second panel of a horizontal one (the inner one needs no size of its own). Nothing animates: resizing is high-frequency interaction. Emits ark-resize with detail: { sizes } on every change; persisting is up to the app. theme, testid. JS: sizes (number[]), direction. The handle track is --ark-split-pane-handle (0.375 rem).
ark-status-dot β the host is the dot (intent background, default neutral, which the apps use for "checking"; success, warning, danger, info...). label turns it into a role="img" with that accessible name; without label it is aria-hidden, for the usual case where the text beside it already says the state. size (xsβxl: 0.375 to 1 rem), theme, testid. Static by design: it never pulses (an attention loop on a status indicator falls under WCAG 2.2.2).
ark-switch β checked, disabled, size, intent, theme, color, labels (shows ON/OFF inside the track; customizable via label-on/label-off), icons (β/β on the thumb), label (accessible name), name/value (form submission when checked). Emits change with detail: { checked }. JS: checked, toggle().
ark-toggle β pressed, value, disabled, size, intent, theme. Emits change with detail: { pressed, value }. JS: pressed, disabled, value, toggle().
ark-toggle-group β value (selected value(s), synced with items), multiple, disabled, size, intent, theme. Emits change with detail: { value } (exclusive) or detail: { values } (multiple). JS: value (comma-separated with multiple), values (array, read-only).
ark-card β the host is the container (surface background, border, corners by rounded, default lg) laid out as a grid from components.css, so your children stay where they are: slot="header" and slot="actions" share the top row (title left, actions right, vertically centered), every child without a slot is the body, full width and in DOM order, and slot="footer" is always the last row, with a divider above it. heading renders the component's own h2 at the start of the host; when it is present, your slot="header" child becomes the next header row, full width (a description, filters, tabs). padding (none/sm/md, default/lg = 0 / 0.75 / 1 / 1.5 rem) sets the host padding and the gap between rows together; with none the content reaches the edges (add overflow-hidden to the host class to clip an image to the corners and pad the text yourself). theme, testid. Not interactive and no role of its own; add role="region" and a label when the card is a landmark.
ark-carousel β the host is the scroll container and your slides are its direct children. slides-per-view, gap (px), start-index, loop, autoplay/autoplay-delay (paused on hover/focus and disabled under prefers-reduced-motion), show-dots/show-arrows ("false" hides), drag-free, snap (mandatory/proximity), intent, accent-color, theme. JS API: index, slides, next(), prev(). Emits ark-slide-change with detail: { index }.
ark-tabs β the host is the role="tablist"; its ark-tab children stay in place and a child with slot="actions" (e.g. a "+" ark-button) is moved to the end of the strip by CSS only. value (active tab; without it the first enabled tab is selected silently), variant (underline, default / chips / editor), size, intent, rounded (tab corners: chips default to full, editor rounds only the top, the others default to none), fill (how the active tab is painted: none, soft or solid with the intent color and contrast text; chips default to soft, the others to none), theme, label (β aria-label), lang/locale-json (for the close and unsaved labels); variant, size, intent, rounded, fill, theme and language are propagated to the tabs. Keyboard: arrows, Home and End move focus and select (automatic activation), skipping disabled tabs. Emits change with detail: { value } (the tab's own event is consumed). When the active tab is removed from the DOM, the neighbour becomes active and change fires. No motion on switch. Panels are the app's: point each tab at its panel with controls. JS: value.
ark-tab β the host is the role="tab" (aria-selected, roving tabindex); put an icon, text or badge as children. value, disabled, controls (β aria-controls), and in the editor variant closable (a close button outside the tab order, plus middle click and the Delete key; emits ark-close with detail: { value } β removing the tab is up to the app) and dirty (a static unsaved dot named for screen readers). JS: value, selected, disabled, closable, dirty, select(), close().
ark-textarea β the ark-input grid and attributes (label, placeholder, value, name, size, intent, theme, rounded, helper, error/error-message, disabled, required, readonly) on a native <textarea>, plus rows (default 3), autosize (grows with the content: field-sizing: content where supported, measured by JS elsewhere), monospace and resize (vertical, default, or none). One row lines up with an ark-input of the same size. Mirrored native attributes: autocomplete, autofocus, maxlength, minlength, spellcheck, wrap, aria-label. Emits native input/change; JS: value, textareaElement, focus().
ark-tooltip β the host wraps your trigger (its first child without a slot; it must be focusable on its own, e.g. a button) without moving it, and creates the bubble (role="tooltip", popover="manual", so it escapes overflow and sits in the top layer). Text via content, or rich content in a child with slot="content", which then is the bubble itself (the component only adds popover, role, an id and the hook to it; its look comes from CSS). side (top, default / bottom / left / right; flips when there is no room), delay (ms before opening on hover, default 200; focus opens immediately), open (reflected), theme, testid. Opens on hover and focus; closes on blur, Esc, pointerdown on the trigger and 100 ms after the pointer leaves both trigger and bubble (time to cross the gap: resting on the bubble keeps it open). The trigger gets aria-describedby pointing at the bubble. Motion: fade in with quick, no exit animation. JS: show(), hide().
ark-toaster β position (top-left β¦ bottom-right), rich-colors, close-button ("false" hides), max-visible (default 4; the rest wait in a queue with their timer paused and show up as others leave), duration (ms, default 4000; 0 keeps toasts until dismissed; a loading toast never dismisses itself), lang, theme. Fed by the toast service from @tooark/core (toast(title, options), toast.success(title, options), β¦) or by the element's own methods, toast({ title, ... }) (a single options object) and dismiss(id?); emits ark-toast-action with detail: { id, actionId } when an action button is clicked. The stack is the component's own popover="manual": it enters the top layer with the first toast and leaves it when empty, with no z-index to fight, and it re-enters on every new toast, so a toast fired while a dialog, drawer or menu is open shows above their scrim (unless keyboard focus is inside a toast, which stays where it is).
announce() (service, @tooark/core) β announce(text, politeness = "polite") speaks a message to screen readers through a single hidden live region appended to document.body (data-ark="announcer", with a role="status" child for polite and a role="alert" child for assertive). Components use it to announce a result in place (a button's status, "copied", chosen files) without creating live regions inside the host, where the text would join the control's accessible name. Calling it again with the same text announces it again; it is a no-op without a DOM.
ark-code-editor (@tooark/code) β a CodeMirror 6 editor as a Custom Element; the CodeMirror packages are peer dependencies (@codemirror/state, view, language, commands, search, autocomplete, lang-json, lang-javascript, lang-yaml, @lezer/highlight), so the page keeps a single copy of each. The text goes through the value property (a value attribute seeds it) and comes back in change with detail: { value } on every user edit (not on programmatic value). language (json / javascript / yaml / text, default), readonly (no editing cursor, no active-line highlight; search and folding still work), placeholder, min-height (CSS length, default 8rem; the editor grows with its content), line-numbers and fold (on by default, "false" turns them off), wrap (visual line wrapping instead of horizontal scrolling), theme (auto, default, follows the page's color-scheme and runtime toggles through observeColorScheme; light/dark force a side), testid. Indentation: indent-style (space, default / tab) and indent-size (spaces per level or the visual width of a tab, default 2) drive Tab, Enter and the JSON formatter; tab-indent is on by default (Tab indents, Shift+Tab outdents) and keyboard users leave the editor with Esc then Tab (CodeMirror's tab-focus mode, also toggled by Ctrl+M, Shift+Alt+M on macOS), or set tab-indent="false" to keep Tab as plain navigation. Line endings: line-ending (auto, default / lf / crlf) applies at the boundary only β the document is LF inside, value and change come out with the configured ending, and auto keeps whatever the last assigned value used (resolvedLineEnding tells which). Completions: the language's own (JavaScript keywords, snippets and local variables), variableKeys (offered after {{, inserting {{key}} or just the key when the closing }} is already there), completions (words offered in any language, CodeMirror's { label, type?, detail?, info?, apply? }), completionSource (a CodeMirror source with context); Ctrl+Space opens the list any time and autocomplete="false" turns everything off. Formatting: format() (also Shift+Alt+F) reformats the document β JSON works out of the box with the configured indentation, other languages need the formatter(value, language) JS property (Prettier, js-yamlβ¦ stays in the app); invalid JSON or a throwing formatter emits ark-format-error with detail: { error } and resolves false; a successful format is a real edit (undo history, change), and canFormat says whether the current language can be formatted. Opt-in extras, off until you set them: variables ({ key, scope?, intent?, value? }[]) paints each {{key}} with the intent's soft colors (inside JSON strings too) with a scope and value tooltip, mark-unknown-variables marks a {{key}} outside variables/variableKeys in danger, and single-line makes a one-line field at the control height of size (xsβxl, which also scales the font) where Enter emits ark-submit with detail: { value }. aria-label names the editable content (CodeMirror's role="textbox"). JS: value, variableKeys, variables, markUnknownVariables, singleLine, size, completions, completionSource, formatter, format(), canFormat, language, readonly, wrap, lineNumbers, fold, placeholder, minHeight, indentStyle, indentSize, lineEnding, resolvedLineEnding, tabIndent, autocomplete, theme, resolvedTheme, view (the EditorView), focus(). Keys: Ctrl/Cmd+F search panel, Ctrl/Cmd+Z undo, Ctrl+Y redo (Cmd+Shift+Z on macOS), Ctrl+Space completions, Shift+Alt+F format. The chrome (surface, text, borders, selection, gutters, tooltips) reads the --ark-color-* tokens with fallbacks, so it matches the app theme even without @tooark/web-components styles; the syntax colors are two fixed palettes. createCodeEditor(parent, options) from the same package gives the imperative engine (getValue/setValue, every set*, format, resolvedTheme, focus, destroy, view) without the element, and registerTooarkCode() defines it.
ark-wysiwyg-editor / ark-wysiwyg-viewer (@tooark/wysiwyg) β Tiptap editor and read-only viewer sharing one schema. Content travels as Tiptap JSON through the content property (never raw HTML) and is sanitized on the way in by sanitizeWysiwygContent: nodes and marks outside the schema are dropped, href/src/poster must be http(s), mailto, tel or a relative path starting with /, #, ?, ./ or ../ (javascript:, data:, blob: and any other scheme are removed), colors must be a hex value, a CSS color name or rgb()/hsl() and textAlign/heading level are clamped; the viewer renders links with target="_blank" and rel="noopener noreferrer nofollow". Setting content does not emit ark-wysiwyg-change and is kept out of the undo history. toolbar lists groups and/or items, comma-separated: style (a native select with Normal and Heading 1β4), marks (bold, italic, underline, strike, code), color (text color and highlight, each a palette popover fed by colors/highlights, JSON arrays of colors in those same forms (hex, CSS name, rgb()/hsl(); anything else is dropped), default nine mid-tones and six pastels), align (left, center, right, justify), lists (bulleted, numbered, indent/outdent β list items only), link (popover with the URL, applies to the selection or inserts the URL as linked text, prefixes https:// to a bare host, refuses unsafe schemes inline), media (image, video), blocks (quote, rule), clear (clear formatting) and history; all enables everything, none hides the bar, and the default is style,marks,lists,link,blocks,clear,history. The toolbar is a role="toolbar" with a single tab stop (arrows, Home/End) and its labels follow lang (en, default / pt / es) with locale-json overrides. Media never becomes base64: set the uploadFile(file, kind) JS property to a function that stores the file wherever the app wants and resolves { src, alt?, title?, poster? }; the editor inserts only that URL (validated with the same allow-list). Without the hook the media group is not rendered even with all, and pasted or dropped files are refused; max-file-size (bytes, default 10 MiB) and unsupported types are refused too, always with ark-wysiwyg-upload-error (detail: { reason, file, error? }, reasons no-uploader, unsupported-type, too-large, invalid-src, failed). theme (auto, default, follows the page at runtime / light / dark), placeholder, editable="false", aria-label (names the editable content, Tiptap's role="textbox"), testid (data-ark="wysiwyg-editor" on the root with -toolbar and -content parts; the viewer wysiwyg-viewer and -content). JS: content, uploadFile, colors, highlights, insertFile(file), resolvedTheme, editor (the Tiptap instance); the same engine is exposed as createWysiwygEditor/createWysiwygViewer without the elements.
Overlay helpers (@tooark/core) β the pieces ark-dialog is built from, for overlays of your own on the Popover API. trapFocus(container, { initial, returnTo, onOutsidePointer }) returns a release function: Tab and Shift+Tab cycle through the container's focusables, focus that escapes comes back, pointer events that start outside are cancelled at capture for the whole gesture, so a scrim click that closes the overlay never activates what is behind it (the pointerdown is reported to onOutsidePointer, e.g. to close on the scrim), popovers opened on top are left alone, nested traps stack, and releasing restores focus to returnTo (default: the element focused at activation). openPopover(host, preset, options) calls showPopover() then arkEnter; closePopover(host, preset, options) runs arkExit first and only then hidePopover(), and a reopen during the exit abandons it. openPopover also takes lockScroll: true (what ark-dialog, ark-drawer and ark-command-palette pass): lockScroll(owner) sets overflow: hidden on the root element plus a padding-right the size of the scrollbar that disappeared (also exposed as --ark-scroll-lock-gap, for fixed elements of your own to compensate), reference-counted by owner so stacked overlays release only when the last one closes; closePopover calls unlockScroll(owner) after the exit, and isScrollLocked() tells whether any lock is active. positionAnchored(panel, anchor, { side, align, offset, padding, onPlace }) places a position: fixed panel next to an element or a { x, y, width, height } rect from getBoundingClientRect(): preferred side with a flip to the opposite one when there is no room, alignment along that side, sliding to stay inside the viewport; it writes left/top/transform-origin inline plus data-ark-side/data-ark-align on the panel and repositions on scroll and resize until the returned dispose runs. focusableElements(root) lists the tabbable elements in DOM order; isPopoverOpen(host) checks :popover-open.
Motion is designed in three layers so the components stay dependency-free:
- Tokens (
@tooark/tokens) β durations (--ark-duration-none/instant/quick/default/moderate/gentle/slow/long, 0β1000 ms), easing curves (--ark-ease-linear/standard/in/out/in-out/overshoot/sheet) and the slide distance.prefers-reduced-motionzeroes every duration at the token level, covering the whole system at once. - Presets (
@tooark/core) β zero-dependency CSS keyframes/classes (.ark-animate-*,.ark-skeleton,.ark-skeleton-animated) and WAAPI helpers (arkEnter,arkExit) used by the components themselves (e.g. toast enter/exit). @tooark/motion(opt-in) βarkStaggerEnter,arkReveal,arkFlipandarkSwipeon top of the Motion library, for spring physics and scroll-driven effects. Only projects that install this package pay for the library.
Duration scale (ArkDuration in TypeScript, --ark-duration-* in CSS, ARK_DURATION_MS as the JS mirror). arkEnter, arkExit, arkStaggerEnter and arkReveal accept either a token name or a raw number in milliseconds (arkFlip and arkSwipe run on springs and take no duration):
| Token | Value | Intended use |
|---|---|---|
none |
0 ms | Disables the transition (what every token becomes under prefers-reduced-motion). |
instant |
75 ms | Micro-feedback: hover, focus ring, pressed state. |
quick |
150 ms | Small elements entering/leaving (popups, tooltips, calendar grid); default for arkExit. |
default |
250 ms | Default for arkEnter, arkStaggerEnter and the .ark-animate-* presets. |
moderate |
350 ms | Emphatic feedback (.ark-animate-shake) and medium-sized surfaces. |
gentle |
500 ms | Large surfaces: panels, drawers, page-level transitions. |
slow |
700 ms | Orchestrated sequences and staggered lists. |
long |
1000 ms | Ambient motion: loaders, progress, attention loops. |
Easing curves (ArkEasing in TypeScript, --ark-ease-* in CSS, ARK_EASING_CSS as the JS mirror). The helpers also accept any CSS timing function as a string, and @tooark/motion accepts a cubic-bezier array or a Motion easing name:
| Token | Curve | Intended use |
|---|---|---|
linear |
linear |
Continuous motion: spinners, progress, marquee (.ark-animate-spin). |
standard |
cubic-bezier(0.2, 0, 0, 1) |
General-purpose transitions between on-screen states. |
in |
cubic-bezier(0.4, 0, 1, 1) |
Accelerating exits; default for arkExit. |
out |
cubic-bezier(0, 0, 0.2, 1) |
Decelerating enters; default for arkEnter, arkStaggerEnter and the .ark-animate-* presets. |
in-out |
cubic-bezier(0.4, 0, 0.2, 1) |
Symmetric state changes: shake, pulse. |
overshoot |
cubic-bezier(0.34, 1.56, 0.64, 1) |
Playful enters that overshoot and settle (Motion's backOut). For real spring physics use @tooark/motion. |
sheet |
cubic-bezier(0.32, 0.72, 0, 1) |
Sheets and drawers sliding in from an edge: fast start, long settle (ark-drawer enter). |
Motion tokens are overridden like the color tokens, with one rule: keep duration overrides inside @media (prefers-reduced-motion: no-preference). Under reduce everything the library animates stops: the .ark-animate-* presets and the JS helpers run at 0 ms regardless of --ark-animate-duration or of a token redefined on a subtree, and the root tokens are zeroed with !important, so a :root override written outside that media query cannot re-enable your own token-driven CSS by accident. To deliberately keep motion under reduced motion, declare the token with !important (or override the preset's animation-duration) inside your own @media (prefers-reduced-motion: reduce) block.
@media (prefers-reduced-motion: no-preference) {
:root {
--ark-duration-default: 180ms;
--ark-duration-gentle: 400ms;
}
}Continuous loaders are exempt from reduced motion by design. .ark-animate-spin (the spinner inside ark-button) keeps spinning under prefers-reduced-motion: reduce: reduced motion exists to avoid vestibular discomfort, which a 1 em rotation does not cause, while a frozen spinner removes the only sign that something is in progress. That is also why it runs on a fixed 1s instead of a duration token, which the zeroing would freeze. Attention loops do stop: .ark-animate-shake, .ark-animate-pulse and .ark-skeleton-animated. .ark-skeleton itself is static by default (an infinite pulse on the one region with nothing to read draws the eye and falls under WCAG 2.2.2); add .ark-skeleton-animated to opt into the shimmer (a highlight sweeping left to right; shift its phase with a negative animation-delay so neighbours do not sweep together, as ark-skeleton does), and give the content that replaces a skeleton .ark-animate-fade-in if you want it to ease in.
Live examples of every component, with their interaction tests, are in the Storybook. The sections below take you from installation to a working form in each environment.
All @tooark/* packages are published together, with one version and TypeScript types, as ESM + CJS (the React, Vue and Angular wrappers are ESM only). Install the package for your stack. A framework wrapper pulls @tooark/web-components, @tooark/core and @tooark/tokens along, but a package manager with isolated dependencies (pnpm) only lets you import your direct ones, so the table also lists @tooark/web-components, whose stylesheet you import:
| Stack | Install | Peer dependencies you provide |
|---|---|---|
| Vanilla / any framework | pnpm add @tooark/web-components (plus @tooark/core to call toast() or announce()) |
β |
| React 18 / 19 | pnpm add @tooark/react @tooark/web-components |
react, react-dom β₯ 18 |
| Vue 3 | pnpm add @tooark/vue @tooark/web-components |
vue β₯ 3 |
| Angular | pnpm add @tooark/angular @tooark/web-components |
@angular/core, @angular/common β₯ 21.2.19 |
Charts (ark-chart) |
pnpm add @tooark/chart echarts |
echarts β₯ 5 |
Rich text (ark-wysiwyg-*) |
pnpm add @tooark/wysiwyg |
β (Tiptap comes as a dependency) |
Code editor (ark-code-editor) |
pnpm add @tooark/code @codemirror/state @codemirror/view @codemirror/language @codemirror/commands @codemirror/search @codemirror/autocomplete @codemirror/lang-json @codemirror/lang-javascript @codemirror/lang-yaml @lezer/highlight |
the CodeMirror packages, so your page keeps one copy of each |
| Advanced motion | pnpm add @tooark/motion |
β (the Motion library comes as a dependency) |
Two things every setup needs, whatever the framework: import the stylesheet once (@tooark/web-components/styles.css, the only CSS you need β tokens, motion presets and component styles) and register the elements (registerTooarkComponents(); the React, Vue and Angular wrappers do this for you on first render). The side packages register their own elements (registerTooarkChart(), registerTooarkWysiwyg(), registerTooarkCode()).
import { registerTooarkComponents } from "@tooark/web-components";
import "@tooark/web-components/styles.css";
registerTooarkComponents();styles.css is safe to load next to your own CSS framework:
- No global reset. Tailwind's preflight is not shipped; a scoped reset applies only inside
ark-*elements. - Prefixed utilities and variables. Every component class is
ark:*(e.g.ark:inline-flex) and theme variables are--ark-*(e.g.--ark-color-primary), so nothing collides with a Tailwind v3/v4 setup in your app.
A form with a field, a select, a confirmation dialog and a toast β the same scenario the framework sections below reproduce:
<form id="profile">
<ark-input name="name" label="Name" placeholder="Your full name" helper="As on your ID" required></ark-input>
<ark-select
name="role"
label="Role"
options='[{"value":"dev","label":"Developer"},{"value":"ops","label":"Operations"}]'
></ark-select>
<ark-datepicker input mode="date" lang="en" name="since"></ark-datepicker>
<ark-button type="submit" intent="primary">Save</ark-button>
</form>
<ark-dialog id="confirm" label="Publish changes?" lang="en">
<p>Your profile will be visible to the whole team.</p>
<div slot="footer">
<ark-button variant="ghost" data-action="cancel">Cancel</ark-button>
<ark-button intent="primary" data-action="publish">Publish</ark-button>
</div>
</ark-dialog>
<ark-toaster position="bottom-right" lang="en"></ark-toaster>import { toast } from "@tooark/core";
const form = document.querySelector<HTMLFormElement>("#profile")!;
const dialog = document.querySelector("ark-dialog")!; // `show()`, `close(reason)`, `open`
form.addEventListener("submit", (event) => {
event.preventDefault();
dialog.show(); // popover on the top layer: focus trapped, Esc and the scrim close it
});
dialog.addEventListener("click", (event) => {
const action = (event.target as HTMLElement).closest("[data-action]")?.getAttribute("data-action");
if (action === "publish") {
const data = Object.fromEntries(new FormData(form)); // { name, role, since: "YYYY-MM-DD" }
toast.success("Profile published", { description: `Welcome, ${data.name}.` });
}
if (action) dialog.close();
});
// Every ark-* element also works as a plain DOM API: attributes, properties and custom events.
document.querySelector("ark-select")!.addEventListener("change", (event) => {
console.log((event as CustomEvent<{ value: string }>).detail.value);
});events on ark-calendar/ark-scheduler, rows on ark-kv-editor and options on ark-select also accept the JS property, which avoids serializing JSON into the attribute:
document.querySelector("ark-scheduler")!.events = [
{ id: "1", title: "Daily", start: "2026-09-01T09:00", end: "2026-09-01T09:15", intent: "info" },
];@tooark/react exports one component per element (ArkButton, ArkInput, ArkDialog, β¦) with camelCase, typed props, plus the toast service re-exported from @tooark/core. List props (events, options, colors) are serialized for you and rows is set as the JS property; localeJson takes an object on ArkCalendar and ArkDatepicker and a JSON string on the other components. Custom events become handlers receiving the CustomEvent (onChange, onClose, onSelect, β¦), except on ArkCalendar, ArkDatepicker, ArkClock, ArkCarousel and ArkScheduler, whose handlers (onChange, onSlideChange, onEventClick, β¦) receive the detail. ArkButtonProps also accepts the typed DOM attributes and handlers (onClick, onFocus, id, style, β¦). Import the stylesheet once (in main.tsx or your root layout) and use the components anywhere:
// main.tsx
import "@tooark/web-components/styles.css";import { ArkButton, ArkDialog, ArkInput, ArkSelect, ArkToaster, toast } from "@tooark/react";
import { type FormEvent, useState } from "react";
const ROLES = [
{ value: "dev", label: "Developer" },
{ value: "ops", label: "Operations" },
];
export function ProfileForm() {
const [name, setName] = useState("");
const [role, setRole] = useState("dev");
const [confirming, setConfirming] = useState(false);
function submit(event: FormEvent) {
event.preventDefault();
setConfirming(true);
}
function publish() {
setConfirming(false);
toast.success("Profile published", { description: `Welcome, ${name}.` });
}
return (
<form onSubmit={submit}>
{/* Native events bubble from the inner control: read the value from event.target */}
<ArkInput label="Name" value={name} required onInput={(e) => setName((e.target as HTMLInputElement).value)} />
{/* ark-select consolidates its own `change` CustomEvent with detail.value */}
<ArkSelect label="Role" options={ROLES} value={role} onChange={(e) => setRole(e.detail.value)} />
<ArkButton type="submit" intent="primary">
Save
</ArkButton>
{/* `open` is the source of truth: turning it off animates the exit (unmounting closes without animation) */}
<ArkDialog label="Publish changes?" open={confirming} onClose={() => setConfirming(false)}>
<p>Your profile will be visible to the whole team.</p>
<div slot="footer">
<ArkButton variant="ghost" onClick={() => setConfirming(false)}>
Cancel
</ArkButton>
<ArkButton intent="primary" onClick={publish}>
Publish
</ArkButton>
</div>
</ArkDialog>
<ArkToaster position="bottom-right" />
</form>
);
}Works with React 18 (attributes) and React 19 (properties): the boolean setters of every element accept both (see coerceBooleanAttr in the notes below). Every wrapper forwards ref to its ark-* element, typed as the element class (React.ComponentRef<typeof ArkDialog>), for methods such as show() or focus(). Every props type also takes React's DOM attributes and handlers (id, style, aria-*, onClick, β¦), typed and forwarded to the element. For elements without a wrapper (the side packages), use the tag directly with registerTooark*() in an effect and a ref to set the JS properties; @tooark/react ships the IntrinsicElements typings for every ark-* tag.
@tooark/vue exports one component per element with typed props, plus the toast service re-exported from @tooark/core; custom events keep their native names (@ark-change, @ark-close, @ark-select, β¦) and deliver the CustomEvent, except @ark-change of ArkCalendar, ArkDatepicker and ArkClock, @ark-slide-change of ArkCarousel and the ArkScheduler events, which deliver the detail. Import the stylesheet once (in main.ts) and use the components in any SFC:
// main.ts
import "@tooark/web-components/styles.css";<script setup lang="ts">
import { ArkButton, ArkDialog, ArkInput, ArkSelect, ArkToaster, toast } from "@tooark/vue";
import { ref } from "vue";
const roles = [
{ value: "dev", label: "Developer" },
{ value: "ops", label: "Operations" },
];
const name = ref("");
const role = ref("dev");
const confirming = ref(false);
function publish() {
confirming.value = false;
toast.success("Profile published", { description: `Welcome, ${name.value}.` });
}
</script>
<template>
<form @submit.prevent="confirming = true">
<!-- Native events bubble from the inner control: read the value from event.target -->
<ArkInput label="Name" :value="name" required @input="name = ($event.target as HTMLInputElement).value" />
<!-- ark-select consolidates its own `change` CustomEvent with detail.value -->
<ArkSelect label="Role" :options="roles" :value="role" @change="role = $event.detail.value" />
<ArkButton type="submit" intent="primary">Save</ArkButton>
<ArkDialog label="Publish changes?" :open="confirming" @ark-close="confirming = false">
<p>Your profile will be visible to the whole team.</p>
<div slot="footer">
<ArkButton variant="ghost" @click="confirming = false">Cancel</ArkButton>
<ArkButton intent="primary" @click="publish">Publish</ArkButton>
</div>
</ArkDialog>
<ArkToaster position="bottom-right" />
</form>
</template>Tell Vue's compiler that ark-* tags are custom elements when you use one without a wrapper (side packages): compilerOptions.isCustomElement = (tag) => tag.startsWith("ark-") in vite.config.ts (plugins: [vue({ template: { compilerOptions } })]).
@tooark/angular exports one standalone component per element (ArkInputComponent, ArkDialogComponent, β¦) with the <ark-*-wrapper> selector, plus the toast service re-exported from @tooark/core: @Input()s for the attributes and @Output()s re-emitting the custom events (changed, arkChange, arkClose, arkSelect, β¦) with the CustomEvent, except the outputs of the calendar, clock, datepicker, carousel and scheduler, which emit the detail. localeJson is a JSON string, except on the calendar and datepicker, which take an object, and the kv-editor's rows and valueField are set as JS properties. Every wrapper exposes the ark-* element it renders as element, typed as the element class and null before the view exists, for the element's methods and properties (@ViewChild(ArkDialogComponent) dialog!: ArkDialogComponent;, then this.dialog.element?.show()). The wrapper is an element of its own around the ark-* one, so only the inputs reach the element: an id, class, style or data-* written on the <ark-*-wrapper> tag stays on the wrapper (use element for those), while native events such as (click) bubble up to it. Add the stylesheet to angular.json (the path exists because @tooark/web-components is a direct dependency, see Installation) and import the components you use:
// angular.json β projects.<app>.architect.build.options
"styles": ["node_modules/@tooark/web-components/dist/styles.css", "src/styles.css"]import { Component } from "@angular/core";
import {
ArkButtonComponent,
ArkDialogComponent,
ArkInputComponent,
ArkSelectComponent,
ArkToasterComponent,
toast,
} from "@tooark/angular";
@Component({
selector: "app-profile-form",
standalone: true,
imports: [ArkInputComponent, ArkSelectComponent, ArkButtonComponent, ArkDialogComponent, ArkToasterComponent],
template: `
<form (submit)="$event.preventDefault(); confirming = true">
<!-- Native events bubble from the inner control: read the value from $event.target -->
<ark-input-wrapper
label="Name"
[value]="name"
[required]="true"
(input)="name = $any($event.target).value"
></ark-input-wrapper>
<!-- ark-select consolidates its own change event, re-emitted as (changed) with detail.value -->
<ark-select-wrapper
label="Role"
[options]="roles"
[value]="role"
(changed)="role = $event.detail.value"
></ark-select-wrapper>
<ark-button-wrapper type="submit" intent="primary">Save</ark-button-wrapper>
<ark-dialog-wrapper label="Publish changes?" [open]="confirming" (arkClose)="confirming = false">
<p>Your profile will be visible to the whole team.</p>
<div slot="footer">
<ark-button-wrapper variant="ghost" (click)="confirming = false">Cancel</ark-button-wrapper>
<ark-button-wrapper intent="primary" (click)="publish()">Publish</ark-button-wrapper>
</div>
</ark-dialog-wrapper>
<ark-toaster-wrapper position="bottom-right"></ark-toaster-wrapper>
</form>
`,
})
export class ProfileFormComponent {
roles = [
{ value: "dev", label: "Developer" },
{ value: "ops", label: "Operations" },
];
name = "";
role = "dev";
confirming = false;
publish(): void {
this.confirming = false;
toast.success("Profile published", { description: `Welcome, ${this.name}.` });
}
}The package is built with ng-packagr in partial Ivy compilation, so it works in AOT production builds, and requires Angular β₯ 21.2.19 (the floor the workspace enforces for security fixes). The custom elements are registered lazily in each wrapper constructor and skipped on the server, so the wrappers are SSR-safe. To use an ark-* tag without a wrapper (side packages), add schemas: [CUSTOM_ELEMENTS_SCHEMA] to the component and call the registerTooark*() function in the browser.
The side packages ship their own Custom Elements, with the same theming (theme="auto" follows the page's color-scheme, including runtime toggles) and no framework wrapper β use the tag directly in React, Vue or Angular as described above. A JS property assigned before registerTooark*() runs (a framework binding option, value or content to the tag before registration) is picked up when the element upgrades, so registering in main.ts or in the component's effect both work.
import { registerTooarkChart } from "@tooark/chart"; // ECharts is a peer dependency
registerTooarkChart();
const chart = document.querySelector("ark-chart")!;
chart.option = {
xAxis: { type: "category", data: ["Mon", "Tue", "Wed"] },
yAxis: {},
series: [{ type: "bar", data: [120, 200, 150] }],
};import { registerTooarkWysiwyg } from "@tooark/wysiwyg"; // Tiptap comes as a dependency
registerTooarkWysiwyg();
const editor = document.querySelector("ark-wysiwyg-editor")!;
editor.setAttribute("toolbar", "style,marks,color,lists,link,media,history"); // opt-in groups
editor.uploadFile = async (file) => ({ src: await uploadToMyStorage(file) }); // enables image/video; the JSON keeps only the URL
editor.content = savedJson; // Tiptap JSON, sanitized on the way in
editor.addEventListener("ark-wysiwyg-change", (event) => save(event.detail));
document.querySelector("ark-wysiwyg-viewer")!.content = savedJson;import { registerTooarkCode } from "@tooark/code"; // CodeMirror packages are peer dependencies
registerTooarkCode();
const editor = document.querySelector("ark-code-editor")!;
editor.setAttribute("language", "json");
editor.variableKeys = ["baseUrl", "token"]; // completions after {{
editor.value = JSON.stringify(body, null, 2);
editor.addEventListener("change", (event) => save(event.detail.value));import { toast } from "@tooark/core"; // also re-exported by @tooark/react, @tooark/vue and @tooark/angular
toast.success("Saved", { description: "Your changes were published." });The elements need a browser: customElements does not exist on the server. Importing any @tooark/* package there is safe (outside the browser the elements extend an empty base class and registerTooark*() does nothing; CI checks it with pnpm check:ssr). Render the markup normally (an unregistered ark-* tag is inert HTML, its light-DOM children are still there for SEO and first paint) and register on the client β the wrappers already do it: React in an effect, Vue in setup and Angular in the constructor, the last two as no-ops on the server. Include styles.css in the server-rendered page so hosts get their box before hydration. Content that only exists in the browser (charts, editors) renders after mount; give the host a min-height if layout shift matters.
React 18 writes attributes on custom elements, React 19 writes the property when the element has one, so a boolean the wrapper passes as "" (present) or leaves out reaches the element either way: every boolean setter (disabled, open, checked, β¦) accepts true/"" as on and false/"false"/null/undefined as off (coerceBooleanAttr from @tooark/core, if you write elements of your own).
Every ark-* element, from any package, also takes JS properties assigned before its registerTooark*() call: a framework that binds rows, options, value or open to a raw tag it rendered before registration leaves them on the node, and the element re-applies them through its setters when it upgrades. An overlay opened that way, or by an open attribute in server-rendered HTML registered later, opens in connectedCallback with ark-open in a microtask, like one created open.
Every color the components use is a semantic token with a light and a dark value (light-dark()), so theming is pure CSS:
- Theme: by default components inherit the page's
color-scheme, like native controls: a light page gets light components even on a dark OS, and an app that declares:root { color-scheme: light dark }follows the system.theme="light"ortheme="dark"on an element forces one side for it and its descendants. No JavaScript is involved. - Brand: override the tokens in your own CSS. Inside the component stylesheet they carry the
arkprefix:
:root {
--ark-color-primary: light-dark(oklch(45% 0.2 264), oklch(80% 0.15 264));
--ark-color-primary-fg: #fff;
--ark-color-primary-hover: light-dark(oklch(40% 0.2 264), oklch(85% 0.15 264));
}Each intent (primary, secondary, success, warning, danger, info, neutral) has <intent>, -fg, -hover, -soft, -soft-fg, -border and -ring; neutral surfaces are surface, surface-muted, surface-strong, surface-raised, fg, fg-soft, fg-muted, fg-faint, fg-placeholder, border, border-strong, muted and ring. The full list with its roles lives in tokens.css.
Two more scales are shared by the form controls:
size(xs/sm/md/lg/xl) maps to the--ark-size-*tokens (1.5 / 1.75 / 2.25 / 2.75 / 3.25 rem, i.e. 24 to 52 px).ark-button,ark-inputandark-toggleapply the token asmin-height(and asmin-widthon icon-only buttons), so controls of the same size line up in a row and overriding--ark-size-mdresizes every control at once;ark-switch,ark-checkboxandark-radiouse proportional dimensions on the spacing scale instead (track and box are glyphs beside text, not full-height controls).rounded(none/xs/sm/md/lg/xl/full) maps to Tailwind's radius scale (--ark-radius-xsβ¦--ark-radius-xl, 0.125 to 0.75 rem).
If your app already has its own design variables (a generated theme, another design system), bridge them to the --ark-* tokens instead of duplicating values. Three things decide how the components look:
-
color-schemeon the page. The tokens arelight-dark()values, so the components read the page'scolor-schemeand nothing else: without a declaration every component renders light, whatever the OS preference. Declare it wherever your app toggles its theme:html { color-scheme: light; } html.dark { color-scheme: dark; } /* or [data-theme="dark"]; `light dark` follows the system */
-
Brand bridge. Point the tokens at your variables.
light-dark()is not needed when your variables already switch with the theme, and every token you leave alone keeps its default::root { --ark-color-primary: var(--brand); --ark-color-primary-fg: var(--brand-fg); --ark-color-primary-hover: var(--brand-hover); --ark-color-primary-soft: var(--brand-soft); --ark-color-primary-soft-fg: var(--brand-soft-fg); --ark-color-primary-border: var(--brand-border); --ark-color-primary-ring: var(--brand-ring); }
If your theme also defines neutrals, bridge
--ark-color-surface*,--ark-color-fg*and--ark-color-border*as well; otherwise your greys and the components' greys come from two sources. -
Density.
--ark-size-*sets the control heights and--ark-text-xs/--ark-text-smmost of the text inside the controls; the micro-labels (the floating label ofxs/smfields, the calendar and scheduler chips, the clock column labels,xsavatars and kbd) sit one step below on--ark-text-2xs(0.6875 rem, no line-height of its own). Only the labels inside the switch track and the calendar count badge keep fixed pixel sizes, because they are bound to the geometry of their container::root { --ark-size-sm: 2rem; /* 32 px */ --ark-size-md: 2.5rem; /* 40 px */ --ark-size-lg: 3rem; /* 48 px */ --ark-text-sm: 0.75rem; }
Duration and easing overrides follow the rule from the motion system: keep them inside @media (prefers-reduced-motion: no-preference).
ark-chart, ark-wysiwyg-editor/ark-wysiwyg-viewer and ark-code-editor cannot be themed by CSS alone (ECharts, Tiptap and CodeMirror paint their own colors), so their theme="auto" resolves the host's computed color-scheme when the element is created: a page that forces dark gets a dark chart, a page that leaves it at light dark (or undeclared) follows the system preference. The resolution is kept up to date while the element is connected: observeColorScheme(element, onChange) from @tooark/tokens watches the class, style, data-theme and theme attributes of <html> and <body> (where apps switch themes) plus the system preference, so a chart or editor left on auto follows a runtime theme toggle; theme="light|dark" still forces one side.
To reuse the same tokens as utilities (bg-primary, text-fg-muted, β¦) in your own Tailwind v4 project, import them in your CSS entry:
@import "tailwindcss";
@import "@tooark/tokens/tokens.css";This is independent from the component stylesheet: the components carry their own prefixed copy of the theme, so your app's Tailwind configuration never changes how the components look.
Requirements: Node.js β₯ 22 (CI runs on 24) and pnpm 12 (version pinned via packageManager; pnpm switches to it on its own).
pnpm install # install all workspace dependencies
pnpm build # build every package
pnpm dev:storybook # run Storybook at http://localhost:6006
pnpm check:publish # pack every package and lint the tarballs (publint + attw)
pnpm check:ssr # import every build in Node without a DOM and render the React/Vue wrappers to string
pnpm clean # remove build outputs
pnpm check # lint, formatting and import order (Biome)
pnpm check:fix # apply Biome fixes and formattingLibrary packages build with Rollup (tsc for the React and Vue wrappers, ng-packagr for Angular). @tooark/core and @tooark/web-components additionally compile their CSS entry (src/styles/index.css) with the Tailwind CLI into dist/styles.css, then run scripts/check-css.mjs, a smoke test that fails the build if the stylesheet was not compiled or is missing expected classes. Component classes must be written with the ark: prefix (ark:flex, ark:hover:bg-surface-muted); unprefixed utilities are not generated. Storybook processes the same CSS through the @tailwindcss/vite plugin, so there is no PostCSS configuration in the repo.
Linting, formatting and import ordering are handled by Biome (biome.json at the root: 120-column lines, double quotes, no trailing commas). CI runs biome ci before the build, so run pnpm check:fix before committing.
Component tests run with the Storybook Vitest addon: every story is executed as a smoke test in a real Chromium browser (Playwright), plus interaction tests (play functions) and accessibility checks (axe-core via @storybook/addon-a11y), which for now report violations as warnings without failing the run (test: "todo" in .storybook/preview.ts).
pnpm exec playwright install chromium # one-time browser download
pnpm --filter storybook test # run the suite
pnpm --filter storybook exec vitest run --project storybook --coverageTests can also be triggered from the Storybook UI ("Run tests" widget). CI lints, builds every package (including the CSS smoke test), checks the tarballs and the SSR imports (pnpm check:publish, pnpm check:ssr) and then runs the same suite on every push to main and every pull request via GitHub Actions.
Every internal element an @tooark/web-components component creates carries stable hooks for end-to-end tests (so do the side packages' elements: the ark-code-editor root, the ark-chart container and the wysiwyg root, toolbar and content), in two layers:
data-ark(static, zero configuration) β the main element getsdata-ark="<component>"and each internal part getsdata-ark="<component>-<part>". These selectors never break when utility classes change.testid(per instance) β declaretestid="..."on the host and the value is propagated asdata-testidto the main element, suffixed with-<part>on internal parts β the formatgetByTestId(Playwright, Cypress, Testing Library) looks for by default. Also available as a typedtestidprop on the React/Vue/Angular wrappers.
<ark-switch testid="notifications"></ark-switch>
<!-- renders: -->
<button data-ark="switch" data-testid="notifications" role="switch">
<span data-ark="switch-thumb" data-testid="notifications-thumb"></span>
...
</button>// Playwright
await page.getByTestId("notifications").click();
await expect(page.locator('[data-ark="switch-thumb"]')).toBeVisible();
await page.locator('[data-ark="calendar-day"][data-date="2026-09-15"]').click();Hooks per component:
| Component | Main element | Internal parts |
|---|---|---|
ark-alert |
alert |
alert-heading, alert-dismiss; alert-icon / alert-action (mark your slot children) |
ark-avatar |
avatar |
avatar-image (the <img> when src is set), avatar-initials |
ark-badge |
badge |
β |
ark-button |
button |
button-spinner, button-status-icon, button-link (href mode) |
ark-checkbox |
checkbox |
checkbox-label (when label is set), checkbox-helper (when helper is set), checkbox-input (the hidden native input) |
ark-scheduler |
scheduler |
scheduler-header, scheduler-today, scheduler-prev, scheduler-next, scheduler-title, scheduler-views, scheduler-view-{view}, scheduler-body, scheduler-scroller, scheduler-days, scheduler-grid, scheduler-day (+ data-date), scheduler-slot (+ data-start), scheduler-event (+ data-event-id), scheduler-event-more, scheduler-allday, scheduler-now, scheduler-empty |
ark-select |
select |
select-label, select-chevron, select-helper, select-error |
ark-shape-picker |
shape-picker |
shape-picker-option (+ data-value); each option contains an ark-mark |
ark-skeleton |
skeleton |
skeleton-row (each bar when rows > 1) |
ark-spinner |
spinner |
spinner-icon, spinner-label (visually hidden) |
ark-split-pane |
split-pane |
split-pane-handle (each handle) |
ark-status-dot |
status-dot |
β |
ark-switch |
switch |
switch-thumb, switch-label-on, switch-label-off, switch-input |
ark-toggle |
toggle |
β |
ark-toggle-group |
toggle-group |
β |
ark-calendar |
calendar |
calendar-prev, calendar-next, calendar-title, calendar-grid, calendar-day (+ data-date), calendar-months/calendar-month (+ data-month), calendar-years/calendar-year (+ data-year), calendar-event, calendar-event-more, calendar-today, calendar-clear |
ark-clock |
clock |
clock-hours, clock-minutes, clock-seconds, clock-meridiem (options via data-value) |
ark-file-input |
file-input |
file-input-label, file-input-zone, file-input-button, file-input-hint, file-input-list (+ file-input-item per file or file-input-summary with directory, file-input-rejected for a refused folder), file-input-helper/file-input-error; the main hook sits on the hidden native input (setInputFiles in Playwright) |
ark-input |
input |
input-label, input-prefix, input-suffix, input-reveal, input-helper/input-error |
ark-card |
card |
card-heading (the heading h2); card-header, card-actions, card-footer (mark your slot children). The body is not marked: it is free content, possibly another ark-* with its own hooks |
ark-carousel |
carousel |
carousel-overlay, carousel-slide-{i} (on your own slide elements), carousel-arrow-prev, carousel-arrow-next, carousel-dots, carousel-dot-{i} |
ark-color-swatches |
color-swatches |
color-swatches-swatch (+ data-value) |
ark-command-item |
command-item |
β |
ark-command-palette |
command-palette |
command-palette-input (the component's ark-input), command-palette-list (the hidden listbox), command-palette-group (each group header), command-palette-empty; each item carries command-item |
ark-copy-button |
copy-button |
copy-button-icon, copy-button-text (the component's own label, only without children and without icon-only); plus the ark-button parts (button-spinner) |
ark-datepicker |
β | Composition, no main hook of its own: with input the testid goes to the ark-input (its hooks) and the calendar gets <testid>-calendar; inline the calendar gets the testid unsuffixed; the clock always gets <testid>-clock. Its own parts, only with input: datepicker-toggle, datepicker-popup. |
ark-dialog |
dialog |
dialog-header, dialog-title, dialog-close, dialog-footer (marks the user's slot="footer" child) |
ark-drawer |
drawer |
drawer-header, drawer-title, drawer-close, drawer-footer / drawer-actions (mark your slot="footer" / slot="actions" children) |
ark-empty |
empty |
empty-heading, empty-description, empty-icon / empty-action (mark the user's slot children) |
ark-kbd |
kbd |
β |
ark-kv-editor |
kv-editor |
kv-editor-row, kv-editor-enabled, kv-editor-key, kv-editor-value, kv-editor-secret, kv-editor-type, kv-editor-description, kv-editor-delete (per row), kv-editor-add, kv-editor-bulk (the textarea), kv-editor-bulk-toggle, kv-editor-list, kv-editor-empty; the composed controls keep their own data-ark (input, select, checkbox) without a testid |
ark-mark |
mark |
β |
ark-menu |
menu |
β |
ark-menu-item |
menu-item |
menu-item-check; divider and static items carry menu-divider / menu-static as their main hook |
ark-progress |
progress |
progress-track, progress-bar, progress-value |
ark-radio |
radio |
radio-dot, radio-label (when label is set), radio-helper (when helper is set), radio-input (the hidden native input) |
ark-tab |
tab |
tab-close, tab-dirty |
ark-tabs |
tabs |
tabs-actions (marks the user's slot="actions" child) |
ark-textarea |
textarea |
textarea-label, textarea-helper/textarea-error |
ark-tooltip |
tooltip |
tooltip-bubble (the component's bubble or your slot="content" child) |
ark-toaster |
toaster |
toaster-toast (+ data-toast-id), toaster-toast-title, toaster-toast-description, toaster-toast-close, toaster-toast-action, toaster-toast-cancel |
ark-chart |
chart |
the chart container ([part="canvas"]) carries data-ark="chart" and the testid |
ark-wysiwyg-editor |
wysiwyg-editor |
wysiwyg-editor-toolbar (when the toolbar is shown), wysiwyg-editor-content (the editable role="textbox", where tests type) |
ark-wysiwyg-viewer |
wysiwyg-viewer |
wysiwyg-viewer-content |
ark-code-editor |
code-editor |
the CodeMirror root (.cm-editor) carries data-ark="code-editor" and the testid |
For most components the main hook sits on the host element itself, since the host is the control or container. It sits on an internal node for ark-input, ark-textarea and ark-select (the native field), ark-checkbox, ark-radio and ark-switch (the drawn <button>), ark-file-input (the hidden native input), ark-calendar, ark-clock and ark-scheduler (their root container) and ark-toaster (the stack). input-prefix/input-suffix are applied to your own slot="prefix"/slot="suffix" elements and carousel-slide-{i} to your own slides.
Always prefer semantic selectors (getByRole("switch", { name: "..." })) when possible β the hooks are the safety net for repeated instances and visual assertions.
Attributes mirrored on the host. The elements are light DOM and their attributes are the API, so what you write on the host stays there and is mirrored on the native control: placeholder (and aria-label, where it is passed through) is on both ark-input and its <input>, and the same goes for ark-textarea and for the ark-input that ark-datepicker and ark-command-palette compose. A query by that attribute (Testing Library getByPlaceholderText, or getByLabelText with an aria-label; Playwright getByPlaceholder; a CSS [placeholder="β¦"]) finds both and fails as ambiguous. Query the control instead: by role and name (getByRole("textbox", { name: "E-mail" }), which needs a label or aria-label, since Testing Library does not use the placeholder as a name), by the real <label for> that label renders (getByLabelText), by testid (it lands on the native control), or inside the host (within(host).getByPlaceholderText("β¦")).
- Naming: elements
ark-*, TypeScript types/classesArk*, animation helpersark*(arkEnter,arkStaggerEnterβ¦; other helpers such astrapFocus,openPopoverorresolveLocalecarry no prefix), CSS custom properties--ark-*, packages@tooark/*. - Layering: a package may only depend on layers below it. Animation libraries never enter
@tooark/coreor@tooark/web-components. - Light DOM: components never move or wrap the children you declare. The host element is the styled control or container (
ark-button,ark-toggle,ark-toggle-group,ark-input,ark-carousel), so frameworks keep full ownership of their children. - Accessibility:
prefers-reduced-motionis honored globally through the motion tokens; stories run axe-core checks.
Bug reports and feature requests go through the issue templates; CONTRIBUTING.md covers the development workflow, the commit convention (Conventional Commits in Portuguese, DCO sign-off) and the checklist for a new component. Questions and where to find help are in SUPPORT.md. Security issues follow SECURITY.md (private advisories, never a public issue), and everyone in the project space is expected to follow the Code of Conduct.
- β Questions, bugs, feature ideas β see SUPPORT.md for the right channel
- π Security vulnerabilities β do not open a public issue; follow SECURITY.md
If this project helps your workflow, consider supporting its development:
- π GitHub Sponsors
- β Ko-fi
Every contribution helps keep the project maintained and improving. Thank you! π
Licensed under the Apache License 2.0 Β© 2026 Tooark.
Attribution notices live in NOTICE; third-party dependency licenses are documented in THIRD-PARTY-NOTICES.md.