# Colors The semantic color tokens every component paints with, in light and dark. Components never name a color. They name a **role** (`bg-surface`, `text-text-secondary`, `bg-danger-soft`) and the theme decides the value. That is what makes dark mode, a forced-light region and any future theme work without touching a component. The lint step rejects palette colors (`bg-red-500`) and arbitrary colors (`text-[#fff]`) in library code. The values below are read from `packages/ui/src/styles/theme.css` when the site builds. Swatches follow the theme toggle. ## Surfaces and lines | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--background` | `bg-background` | #f4f6f8 | #0e0e12 | The page canvas behind every surface. | | `--surface` | `bg-surface`, `bg-card`, `bg-popover` | #ffffff | #18181f | Raised surfaces: cards, popovers, dialogs, inputs. | | `--surface-muted` | `bg-surface-muted`, `bg-muted`, `bg-secondary`, `bg-accent` | #e8edf1 | #26262f | Recessed areas, hover and selected rows, secondary buttons. | | `--border` | `border-border`, `border-border-subtle` | #e2e8ec | #2a2a33 | Dividers and the default border of every component. | | `--border-strong` | `border-border-strong` | #c9d2da | #3a3a46 | Borders that must read on a muted surface. | | `--input` | `border-input` | #c9d2da | #3a3a46 | The border of form controls. | Stack surfaces from the canvas up: `bg-background` for the page, `bg-surface` for cards and overlays on it, `bg-surface-muted` for recessed or hovered areas inside a surface. ## Text | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--foreground` | `text-foreground`, `text-text-primary` | #1a2530 | #eceff1 | Headings, values, primary text. | | `--text-secondary` | `text-text-secondary` | #4a5462 | #a8acb3 | Labels, descriptions, body copy next to a value. | | `--text-muted` | `text-muted-foreground`, `text-text-muted` | #5c6c7c | #8b95a1 | Hints, placeholders, metadata, disabled text. | | `--text-on-dark` | `text-text-on-dark` | #fcfcfc | #fcfcfc | Text on the topbar and other dark bands. | Use three steps at most on one surface: `text-foreground` for what the user reads first, `text-text-secondary` for labels and descriptions, `text-muted-foreground` for metadata. ## Brand and primary | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--primary` | `bg-primary`, `text-primary`, `border-primary` | #1f5b85 | #4a9dd4 | The primary action and the selected state. | | `--primary-foreground` | `text-primary-foreground` | #ffffff | #0f1620 | Text on a primary fill. | | `--primary-hover` | `bg-primary-hover` | #15466a | #6fb1dd | Hover state of a primary fill. | | `--primary-active` | `bg-primary-active` | #0f3f5e | #92c4e7 | Pressed state of a primary fill. | | `--primary-soft` | `bg-primary-soft` | #dceef7 | #1b3a4f | Tinted background for selected navigation and soft badges. | | `--primary-soft-foreground` | `text-primary-soft-foreground` | #15466a | #92c4e7 | Text on primary-soft. | | `--link` | `text-link` | #246b9c | #6fb1dd | Inline links. | | `--link-hover` | `text-link-hover` | #1f5b85 | #92c4e7 | Hovered links. | | `--focus-ring` | `ring-ring`, `outline-ring` | #1f5b85 | #4a9dd4 | Keyboard focus indicator. | | `--brand-mark` | `text-brand-mark` | #1f5b85 | #ffffff | The brand marks and brand icons: blue in light, white in dark. | | `--brand-accent` | `text-brand-accent`, `bg-brand-accent` | #acdffe | #acdffe | The light brand blue. Decorative use only. | ## Status Four status families, each with a solid color, a soft background and a foreground that reaches WCAG 4.5:1 on the soft background. Badges, banners and toasts use them through the `tone` prop. | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--success` | `text-success`, `bg-success` | #15803d | #22c55e | Completed, healthy, resolved. | | `--success-soft` | `bg-success-soft` | #dcfce7 | #052e16 | Background of a success badge or banner. | | `--success-soft-foreground` | `text-success-soft-foreground` | #166534 | #86efac | Text on success-soft. | | `--warning` | `text-warning`, `bg-warning` | #b45309 | #d97706 | Needs attention, pending, degraded. | | `--warning-soft` | `bg-warning-soft` | #fef3c7 | #451a03 | Background of a warning badge or banner. | | `--warning-soft-foreground` | `text-warning-soft-foreground` | #92400e | #fbbf24 | Text on warning-soft. | | `--danger` | `text-danger`, `bg-danger`, `text-destructive` | #dc2626 | #ef4444 | Failed, destructive, error. | | `--danger-soft` | `bg-danger-soft` | #fee2e2 | #450a0a | Background of a danger badge or banner. | | `--danger-soft-foreground` | `text-danger-soft-foreground` | #b91c1c | #f87171 | Text on danger-soft. | | `--info` | `text-info`, `bg-info` | #1f5b85 | #4a9dd4 | Neutral information, new, processing. | | `--info-soft` | `bg-info-soft` | #e0f2fe | #1b3a4f | Background of an info badge or banner. | | `--info-soft-foreground` | `text-info-soft-foreground` | #075985 | #7dd3fc | Text on info-soft. | ## Severity The severity scale is shared by every Zenox security product. The `-fg` tokens are the hue that stays readable as text on the surface: the darker one in light, the brighter one in dark. Always pair a severity color with its label. | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--severity-critical` | `bg-sev-critical` | #dc2626 | #ef4444 | Critical severity fill. | | `--severity-high` | `bg-sev-high` | #ea580c | #f97316 | High severity fill. | | `--severity-medium` | `bg-sev-medium` | #f59e0b | #fbbf24 | Medium severity fill. | | `--severity-low` | `bg-sev-low` | #3b82f6 | #60a5fa | Low severity fill. | | `--severity-info` | `bg-sev-info`, `text-sev-info` | #6b7280 | #94a3b8 | Unknown or informational severity. | | `--severity-critical-fg` | `text-sev-critical-fg` | #b91c1c | #ef4444 | Critical severity as text on the surface. | | `--severity-high-fg` | `text-sev-high-fg` | #c2410c | #f97316 | High severity as text on the surface. | | `--severity-medium-fg` | `text-sev-medium-fg` | #b45309 | #fbbf24 | Medium severity as text on the surface. | | `--severity-low-fg` | `text-sev-low-fg` | #1d4ed8 | #60a5fa | Low severity as text on the surface. | | `--severity-critical-soft-foreground` | `text-sev-critical-soft-fg` | #b91c1c | #f87171 | Critical severity as text on its soft badge. | | `--severity-high-soft-foreground` | `text-sev-high-soft-fg` | #9a3412 | #f97316 | High severity as text on its soft badge. | | `--severity-medium-soft-foreground` | `text-sev-medium-soft-fg` | #92400e | #fbbf24 | Medium severity as text on its soft badge. | | `--severity-low-soft-foreground` | `text-sev-low-soft-fg` | #1d4ed8 | #60a5fa | Low severity as text on its soft badge. | | `--severity-info-soft-foreground` | `text-sev-info-soft-fg` | #4a5462 | #94a3b8 | Unknown severity as text on its soft badge. | ## Topbar | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--topbar` | `bg-topbar` | #2c3a47 | #1f2a33 | The dark band at the top of every app shell. | | `--topbar-fg` | `text-topbar-fg` | #fcfcfc | #fcfcfc | Text and icons on the topbar. | | `--topbar-muted` | `text-topbar-muted` | #9fb0bd | #9fb0bd | Secondary text on the topbar. | ## Charts A categorical palette for data visualization, in series order. It has no purple or pink by design. Pass the variables to Recharts through the chart config: `color: "var(--chart-1)"`. | Token | Utilities | Light | Dark | Use | | --- | --- | --- | --- | --- | | `--chart-1` | `var(--chart-1)` | #1f5b85 | #4a9dd4 | First series: the brand blue. | | `--chart-2` | `var(--chart-2)` | #16a34a | #22c55e | Second series: green. | | `--chart-3` | `var(--chart-3)` | #ea580c | #f97316 | Third series: orange. | | `--chart-4` | `var(--chart-4)` | #6b7280 | #94a3b8 | Fourth series: neutral. | | `--chart-5` | `var(--chart-5)` | #4796cb | #acdffe | Fifth series: light blue. | | `--chart-line` | `var(--chart-line)` | #dc2626 | #ef4444 | Threshold and reference lines. | ## Aliases Several roles answer to two names. The shadcn names (`bg-card`, `bg-muted`, `text-muted-foreground`, `text-destructive`) are what upstream components are written against; the Zenox names (`bg-surface`, `bg-surface-muted`, `text-text-muted`, `text-danger`) came from the platform. Both resolve to the same value. In new code, prefer the shadcn name where one exists and the Zenox name for roles shadcn does not have (`text-text-secondary`, `border-border-strong`, the status, severity and topbar families). --- # Introduction What @zenoxai/ui is, what it contains and how it is organized. `@zenoxai/ui` is the design system of the Zenox platform. Every Zenox application, starting with the Vydar web app, builds its interface from it, so the platform looks and behaves the same everywhere: the same density, the same colors, the same keyboard behavior. ## What is inside - **Components**: the primitives. Buttons, inputs, dialogs, menus, tables. They are the [shadcn/ui](https://ui.shadcn.com) components in their Base UI flavor (`base-nova`), adapted to the Zenox tokens. Behavior and accessibility come from [Base UI](https://base-ui.com). - **Patterns**: compositions of components that recur across products. Page headers, KPI tiles, status and severity badges, empty and error states, the confirmation dialog. - **Tokens**: the colors, type scale, radius and motion every component is styled with, exposed as Tailwind CSS v4 utilities, in light and dark. - **Brand**: the Zenox and Tellyu marks as components. - **Guidelines**: how to write interface text, where actions and messages go, how to show tables, times and severity, how to handle empty states, and the small layout rules that keep screens consistent. ## Principles 1. **Tokens, never raw values.** A component never names a hex color, a palette color such as `red-500` or a pixel font size. The lint step enforces it. 2. **Dense by default.** The platform is an operations console. Running text is 12.5px, controls are 32px tall, and tables are compact. 3. **Behavior from Base UI.** Focus management, keyboard support and ARIA come from the primitives. Do not reimplement them. 4. **No domain knowledge.** Components know nothing about tenants, permissions, translations or analytics. Text arrives through props, already translated; the app maps its own statuses to the library's tones. 5. **Layers only point down.** `lib` and `hooks` are used by `components`, and `components` by `patterns`. Never the other way around. ## For AI agents Every page of this site has a Markdown twin: append `.md` to the URL. The index for language models is at [/llms.txt](/llms.txt). Inside a project that installs the package, read `node_modules/@zenoxai/ui/ai/USAGE.md` first, then `ai/components.md` for the props of every export. --- # Writing How to write interface text in Zenox products, from button labels to error messages. Interface text is part of the design. A label that names the wrong thing or an error that does not say how to fix it costs the user as much as a broken control. These rules apply to every string a Zenox product shows, in English, Portuguese and Spanish alike, and to the defaults the library's patterns ship with. ## Voice and tone Zenox users investigate threats and fix exposures, often under time pressure. Write like a competent colleague: direct, calm and specific. - **Direct.** Lead with what matters. Cut words that do not change the meaning. - **Calm.** State severity as a fact; never add alarm the data does not carry. No exclamation points. - **Specific.** Name the object and the number: "3 domains expire this week", not "Some items need attention". - **Respectful.** Never blame the user, never joke about a failure, never call a task "simple". | Too formal | Too casual | Right | | ------------------------------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------- | | The requested operation could not be completed due to insufficient permissions. | Oops, you can't do that! | You don't have permission to delete assets. Ask a tenant admin. | | Please be advised that the scan has been successfully scheduled. | Scan's all set, you're good to go. | Scan scheduled for 02:00 | ## Plain language - Prefer short sentences and common words. If a sentence needs a second read, split it. - Use one term for one concept, everywhere. If the product calls it an alert, it is never also a notification, an event or a finding on the next screen. - Spell out acronyms at first use unless the audience uses them daily (IP, URL, CVE, DNS do not need it; "EASM" in onboarding text does). - No idioms, metaphors or slang. They do not translate and do not help the reader. - No judgments about effort or speed: drop "simply", "just", "easily", "quickly". What is easy for one person is not for another. - Software does not want, think or see. "The scan found 4 open ports", not "The scanner thinks there are 4 open ports". Established terms such as "ingest" are fine. - Name the thing. "Select the Domain field", not "Select it". ## Voice, tense and mood - Use the active voice and the present tense: "Zenox checks the certificate every hour." - Use the imperative for instructions: "Enter a domain." Not "You should enter a domain." - Use the passive voice when naming the actor would blame the user: "The file couldn't be uploaded", not "You uploaded an invalid file". - Use the future tense only for something that will happen at a known time: "The report will be sent at 08:00." ## Capitalization **Sentence case for everything:** page and dialog titles, buttons, tabs, menu items, labels, column headers, toasts. Capitalize only the first word and proper nouns. | Right | Wrong | | -------------------------- | -------------------------- | | Create alert rule | Create Alert Rule | | Last seen | Last Seen | | Connect Microsoft Entra ID | Connect microsoft entra id | - Product, company and standard names keep their own capitalization: Zenox, Vydar, Tellyu, Microsoft Entra ID, CVE-2024-3400. - Never type text in capitals. The only uppercase in the interface is the caption style (`text-xs font-semibold uppercase tracking-widest`), applied with CSS, for short group labels such as sidebar sections. ## Punctuation and symbols | Mark | Rule | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Period | End full sentences with one: descriptions, helper text, message bodies. No period on titles, labels, buttons, menu items, tabs, column headers, placeholders, toast titles or empty-state titles. | | Colon | No colon after a form label: the label sits above the field. Use one only to join a label and a value on one line ("Owner: Security team") and to introduce a list. | | Comma | Use the serial comma in English lists: "domains, hosts, and certificates". | | Hyphen | Join compound adjectives before a noun: "case-sensitive value", "high-severity alerts". No spaces around it. | | Dashes | No em or en dashes in interface text. Split the sentence or use a comma. Write ranges with words: "10 to 20". | | Ellipsis | Not in buttons, menu items, labels or placeholders, and not to show progress: loading is a Spinner or a Skeleton. | | Exclamation point | Never. | | Question mark | Only in a confirmation title that asks for a decision: "Delete asset vpn-01?" | | Quotation marks | Straight double quotes (`"`) around names the user typed or chose, inside a sentence: `Rule "Leaked credentials" was deleted`. Not around IDs, dates, numbers or UI labels, and never for emphasis. | | Parentheses | Avoid them in labels and buttons. Never write plurals as "(s)". | | Slash | In sentences, write the word you mean: "Linux or Windows". A spaced slash may join two distinct values in a narrow column header: "City / Country". | | Ampersand | Write "and". Keep `&` only inside a proper name. | | Apostrophe | Contractions are fine in English ("can't", "don't"). Do not make product names possessive: "the Vydar dashboard", not "Vydar's dashboard". | ## Numbers, dates and values Numbers, dates, times, identifiers and empty values follow [Data display](/guidelines/data-display): absolute times in the user's time zone, numbers through the locale's formatters, and a muted dash for an empty value. ## Word choice Use the same verb for the same action across every product and language. The Portuguese and Spanish columns are the reference for the products' translation files. | English | Portuguese | Spanish | Use for | Not | | --------------------- | -------------------- | ------------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------- | | Create | Criar | Crear | Making a new object and saving it | Add (for new objects), New (as a button) | | Add | Adicionar | Agregar | Putting an existing thing into a list or group: add a member, add a domain to a scope | Create | | Edit | Editar | Editar | Changing an existing object | Modify, Update (as a button) | | Save | Salvar | Guardar | Keeping changes to an existing object | Apply (in forms) | | Apply | Aplicar | Aplicar | Applying filters or settings that affect a view | Save | | Submit | Enviar | Enviar | Sending something into a review or workflow | Send (unless it is a message) | | Cancel | Cancelar | Cancelar | Leaving a task without keeping changes | Abort, Dismiss | | Close | Fechar | Cerrar | Closing something that changed nothing (a details panel, an error dialog) | OK | | Delete | Excluir | Eliminar | Destroying something that was saved | Remove, Erase; "Deletar", "Apagar" | | Remove | Remover | Quitar | Taking something out of a list without destroying it | Delete | | Discard | Descartar | Descartar | Throwing away unsaved changes or a draft | Delete | | Duplicate | Duplicar | Duplicar | Creating a new object from an existing one | Copy, Clone | | Copy | Copiar | Copiar | Copying a value to the clipboard | Duplicate | | Export | Exportar | Exportar | Producing a file: "Export to CSV", "Export to PDF" | Download (as a button), product names | | Try again | Tentar novamente | Reintentar | Retrying a failed request | Retry, Reload | | Clear | Limpar | Limpiar | Removing applied filters or a typed value | Reset (unless restoring defaults) | | Show more / Show less | Mostrar mais / menos | Mostrar más / menos | Expanding and collapsing a list | See more, Expand | | Sign in / Sign out | Entrar / Sair | Iniciar sesión / Cerrar sesión | Starting and ending a session | Log in, Login, Log out | - "OK" only acknowledges information. Never pair a question with OK and Cancel, or with Yes and No: name the action instead. - Say "please" only when you are making the user wait or redo work, and not in every message. - Do not say "successfully" ("com sucesso", "con éxito") in a success message: the message itself says it worked. ## Text in components | Element | Rule | Example | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | Button | A verb, plus the object when it is not obvious. One to three words. | Create rule, Export to CSV | | Link | Describe the destination. A link that reads "Learn more" alone says nothing to a screen reader. | Learn about detection rules | | Page title | The object or the task, as a noun phrase. | Alerts, Detection rules | | Dialog title | What happens, or the question to answer. | Invite member, Delete asset vpn-01? | | Field label | A noun phrase naming the value. Never a sentence, never a colon. | Domain, Notification email | | Placeholder | An example of the expected format, only when it helps. Never a replacement for the label; it disappears as the user types. No period. | acme.com | | Helper text | One sentence about format, limits or consequences, with a period. | Separate several domains with commas. | | Tooltip | The name of an icon-only control, or one short sentence of help. | Copy, Refresh | | Tab, menu item | A noun or a short verb phrase, sentence case. | Overview, Export to PDF | | Column header | Singular when each row has one value, plural when a cell lists several. | Owner, Tags | | Badge, status | One or two words, a state not a sentence. | Resolved, In progress | ## Messages Every message answers up to three questions, in this order: what happened, why, and what the user can do. Skip the "why" when it does not help them act. | Type | Write | Example | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Success | A short past-tense statement, the object first. No period, no "successfully". | Asset deleted, 12 alerts resolved | | Error | What went wrong and what to do, in full sentences. No error codes or stack traces in the text: put them in a "Details" section or in the logs. | The report couldn't be generated. Try again in a few minutes. | | Field error | How to fix the value, not that it is wrong. | Enter a domain, such as acme.com. | | Required field | Name the field. | Domain is required. | | Warning | The consequence, then the choice. | 3 of 12 alerts are already closed and won't change. Resolve the other 9? | | Confirmation | Title: the question, naming the object. Body: what changes and whether it can be undone. Button: the same verb as the title. | Delete asset vpn-01? / Its findings and history are deleted too. This can't be undone. / Delete asset | | Information | Context the user should know before acting, one or two sentences. | Results include assets discovered in the last 30 days. | Never show a raw backend message to the user: it can carry internal details or personal data. Map known errors to written messages and fall back to a generic one. When an unexpected error may reach support, show the request ID as a copyable value under the message ("Reference: 7f3a9c"), so support can find the request in the logs. ## Writing for translation Every Zenox interface ships in English, Portuguese and Spanish. - Keep a message in one string, with named variables: `"{count} alerts resolved"`. Never build a sentence by joining fragments; word order changes between languages. - Write singular and plural as separate strings, chosen by count. Never "(s)". - Leave room: Portuguese and Spanish run about 30% longer than English. Do not size a button or a column to the English text. - Do not put a variable where its grammar depends on the word (gender, articles). Write a separate message for each case instead. - Quote names the user typed so the sentence stays readable in every language. --- # Action placement Where actions go on a page, in which order, and which button variant each one takes. Users find an action faster when it sits where they expect it, next to what it changes. These rules decide where an action goes and how much emphasis it gets. ## Principles 1. **Close to what it changes.** An action that affects the whole object goes in the page header; one that affects a table goes in the table toolbar; one that affects a single row goes in that row; one that affects a field goes beside the field. 2. **Navigation left, actions right.** Breadcrumbs, back links and tabs start on the left. Toolbars of actions are right-aligned. 3. **One primary action per view.** Only one `default` (filled) button is visible at a time on a page or in a dialog. Everything else is `outline`, `secondary` or `ghost`. 4. **Hide what the user may not do; disable what they cannot do yet.** A missing permission hides the action. A temporary state (nothing selected, a scan running) disables it, with a tooltip that says why. ## Where actions go | Level | Place | Contains | Component | | -------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | Page | The right side of the page header | Actions on the object or list the page shows: Create, Edit, Delete, Export | [Page Header](/patterns/page-header) `actions` | | Workflow | The bottom of a form, right-aligned; sticky on long forms | Finishing a task: Save, Submit, Cancel | A footer row, [Dialog](/components/dialog) footer | | Section | The header of a card or section | Actions on that section's content | [Card](/components/card) `CardAction`, [Section Heading](/patterns/section-heading) `action` | | Table | The toolbar above the table | Search and filters on the left; Create, bulk actions, Export and view options on the right | Toolbar row above [Table](/components/table) | | Row | The last cell of the row | One frequent action at most, inline; the rest in a menu | [Button](/components/button) `ghost`, [Dropdown Menu](/components/dropdown-menu) | | Value | Beside the value, revealed on hover and focus | Copy, pivot | [Copy Button](/patterns/copy-button) `ValueActions` | | Field | Inside or beside the field | Show a password, generate a key, clear the value | [Input Group](/components/input-group) | ```tsx import { DownloadIcon, PlusIcon, ShieldAlertIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { PageHeader } from "@zenoxai/ui/patterns/page-header" function PageHeaderDemo() { return ( } hint="Every alert raised by your detection rules in the last 90 days." actions={ <> } /> ) } ``` ## Order ### Page header From left to right, inside the right-aligned group: 1. Actions on the object: Edit, Delete, Resolve. 2. Actions on the content: filters, the date window. 3. Actions on the layout: switch view, full screen. 4. Generic actions: Share, Export. When more than three actions compete for the header, keep the most used visible and move the rest into a [Dropdown Menu](/components/dropdown-menu) behind an icon button named "More actions". ### Footers Dialogs, sheets and forms end with their finishing actions, right-aligned. The way out comes first and the primary action last, so the primary action sits at the edge where the eye ends: 1. Alternative actions, if any (Save draft). 2. The way out (Cancel, Close). 3. The primary action (Save, Create rule, Delete asset). This is the order `DialogFooter` and `AlertDialogFooter` lay out, and it reverses on narrow screens so the primary action is on top. Keep it in every footer; mixing orders makes users click the wrong button. ## Emphasis | Action type | Variant | Examples | | --------------------------------------------- | --------------------------------------------------------------- | -------------------------- | | Primary: moves the task forward; one per view | `default` | Save, Create rule, Submit | | Secondary: everything else that matters | `outline` or `secondary` | Export, Edit, Copy | | Tertiary: frequent, low-stakes, dense areas | `ghost` | Row actions, toolbar icons | | Destructive: deletes or cannot be undone | `destructive`, after a [confirmation](/patterns/confirm-dialog) | Delete asset, Revoke key | | Way out: leaves without changes | `outline` or `ghost` | Cancel, Close | | Inline navigation that looks like text | `link` | View all alerts | In a dialog that exists to confirm a destructive action, the destructive button is the primary action: it takes the `destructive` variant and no other button is filled. ## Actions on several items When a table supports selection, its bulk actions appear in the table toolbar while items are selected. - **Enable** an action that works without a selection, or applies to at least one selected item. - **Disable** an action that applies to none of the selected items, or needs a different count (Compare needs two). Show why in a tooltip. - **Ask before a partial run.** When some selected items cannot take the action, say how many and why, and offer to run it on the rest: "3 of 12 alerts are already closed. Resolve the other 9?" with the button "Resolve 9 alerts". - **Keep the selection** after the action, so the user can act again on what failed. - Report the result as described in [Messages and feedback](/guidelines/messages#several-items). ## Rows - Put at most one action inline in a row, and only when users take it often. Everything else goes in a row menu: a `ghost` icon button named `Actions for `. - Make the whole row open the item's details when there is a detail view. Inline actions stop the click from also opening the row. - Reveal copy and pivot actions on hover and keyboard focus with `ValueActions`, so a dense table does not grow a column of icons. ## Menus - Order the items of a row or "More actions" menu by frequency, in groups split by separators. - Destructive items go last, after a separator, with `variant="destructive"`: Delete and Revoke never sit between harmless items, where a slip of the pointer reaches them. - An item that opens a dialog keeps the verb of the dialog's action: "Delete asset" opens the confirmation whose button is "Delete asset". --- # Installation Add @zenoxai/ui to a React application with Tailwind CSS v4. ## Requirements - React 19 - Tailwind CSS 4.1 or later, with the Vite plugin or PostCSS - `lucide-react` 1.x for icons - `recharts` 3.x, only if you use the chart components ## 1. Install the package The package is published to the Zenox private registry. Point the `@zenoxai` scope at it once, in the project's `.npmrc`: ```ini @zenoxai:registry= ``` Then install: ```bash pnpm add @zenoxai/ui lucide-react ``` ## 2. Import the styles In the stylesheet where the app imports Tailwind, import the library right after it: ```css @import "tailwindcss"; @import "@zenoxai/ui/styles.css"; ``` `styles.css` brings the tokens, the dark variant and the animation utilities, and tells Tailwind to scan the library's sources so the classes its components use are generated. ## 3. Load the fonts Import the font files from the application's JavaScript entry, **not** from a stylesheet: ```tsx // src/main.tsx import "@zenoxai/ui/fonts.css" import "./globals.css" ``` Tailwind inlines CSS `@import`s without rewriting the URLs inside them, so importing the fonts from CSS emits no font files and the page silently falls back to the system font. Imported from JavaScript, the bundler handles the files. To confirm it worked, check that `document.fonts.size` is greater than zero in the browser console. ## 4. Mount the app-level pieces ```tsx import { Toaster } from "@zenoxai/ui/components/sonner" import { TooltipProvider } from "@zenoxai/ui/components/tooltip" import { ConfirmDialogHost } from "@zenoxai/ui/patterns/confirm-dialog" export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ## 5. Use a component ```tsx import { Button } from "@zenoxai/ui/components/button" export function SaveButton() { return } ``` Every component and pattern is also exported from the package root (`import { Button } from "@zenoxai/ui"`). Both forms are tree-shaken. ## Dark mode Dark mode follows the `.dark` class on ``. Any theme manager that sets that class works; see [Dark mode](/foundations/dark-mode). --- # Typography Typefaces, the type scale and how to set text in a dense interface. ## Typefaces | Family | Utility | Use | | ------------------------- | ------------------------ | ------------------------------------------------------------------ | | Inter (variable) | `font-sans`, the default | Every piece of interface text. | | JetBrains Mono (variable) | `font-mono` | Identifiers, hashes, IP addresses, code, values the user may copy. | Both load from `@zenoxai/ui/fonts.css`, imported from the app's JavaScript entry (see [Installation](/getting-started/installation#3-load-the-fonts)). Inter renders with the `cv11` (single-storey a) and `ss01` (open digits) features. ## Type scale The scale overrides the Tailwind defaults. The platform is dense: **running text is `text-sm` (12.5px)**, not `text-base`. Sizes and line heights are set in `rem`, so they follow the user's font size setting together with spacing and control heights; the pixels below are at the browser's default 16px. Every line height is a whole pixel. The reasoning is in `docs/decisions/0006-type-scale-and-density.md` in the repository. | Utility | Size | Line height | | --- | --- | --- | | `text-xs` | 11px | 16px | | `text-sm` | 12.5px | 18px | | `text-md` | 14px | 20px | | `text-base` | 16px | 24px | | `text-lg` | 18px | 26px | | `text-xl` | 22px | 30px | | `text-2xl` | 26px | 34px | | `text-3xl` | 31px | 38px | | `text-4xl` | 37px | 44px | | `text-5xl` | 44px | 50px | | Utility | Use | | ----------------- | --------------------------------------------------------------------------------------- | | `text-xs` | Captions, badges, keyboard hints, chart axes. The floor: never a sentence or help text. | | `text-sm` | Body copy, buttons, table cells, form labels, help text, descriptions. | | `text-md` | Compact titles: a Popover title, a small Card title, a table group, a side panel group. | | `text-base` | Container titles (Card, Dialog, Sheet, Drawer, empty states); inputs below `md`. | | `text-lg` | Section headings. | | `text-xl` | The page title, the single `

` of a page. | | `text-2xl` | KPI figures in a [Stat Tile](/patterns/stat-tile), semibold. | | `text-3xl` and up | Marketing surfaces only. | `text-dense-xs` is a deprecated alias of `text-xs`. ## Title levels A title's weight follows its role. Page and section headings organize the page and are semibold. Container titles name the object in a card or an overlay; they repeat many times on a screen and are medium, so they stay below the section heading above them. The compact step is semibold because at 14px a medium title would not stand apart from the 12.5px medium labels and buttons around it. | Level | Size / line | Weight | Components | | --------- | ----------- | -------- | ----------------------------------------------------------------- | | Page | 22 / 30 | semibold | [Page Header](/patterns/page-header) | | Section | 18 / 26 | semibold | [Section Heading](/patterns/section-heading) | | Container | 16 / 24 | medium | Card, Dialog, Alert Dialog, Sheet, Drawer, Empty | | Compact | 14 / 20 | semibold | Popover title, `size="sm"` Card title, table and panel groups | | Body | 12.5 / 18 | normal | Text, cells, descriptions | | Label | 12.5 / 18 | medium | Labels, buttons, links, emphasis; table headers are semibold | | Caption | 11 / 16 | medium | Badges, captions, keyboard hints; uppercase captions are semibold | No title uses `leading-none`: Portuguese and Spanish titles wrap more often than English ones, and a wrapped title needs its line height. ## Weights | Utility | Use | | --------------- | -------------------------------------------------------------------- | | `font-normal` | Body text. | | `font-medium` | Labels, buttons, links, container titles, badges. | | `font-semibold` | Page, section and compact titles; table headers; uppercase captions. | | `font-bold` | Not used: KPI figures are semibold, so a row of them is not heavy. | ## Rules - Sentence case for every title, label and button: "Save changes", not "Save Changes". - Uppercase is for short captions only (`text-xs font-semibold uppercase tracking-widest`), such as sidebar group labels. Never for headings or buttons. - Numbers that line up in columns use `tabular-nums`. - Truncate with `truncate` and give the full value a tooltip or a detail view; never let a cell wrap an identifier mid-way. - Values a user may copy (hashes, IPs, keys) use `font-mono` and offer a [Copy Button](/patterns/copy-button). --- # AI agents How coding agents find and use the design system, in this repository and in the apps that consume it. The design system is written to be read by coding agents as well as people. Everything an agent needs is plain text, versioned with the package, and reachable without a browser. ## In an app that uses the package The package ships its own documentation in `node_modules/@zenoxai/ui/ai/`: | File | What | | ----------------- | ------------------------------------------------------------------------------------------------------------- | | `USAGE.md` | Setup, the rules (tokens, composition, accessibility) and which component to use for which job. Hand-written. | | `components.md` | Every export and its props, generated from the sources of the installed version. | | `components.json` | The same, as JSON. | Point the app's agents at it. In the app's `AGENTS.md` or `CLAUDE.md`: ```md ## UI Build UI with @zenoxai/ui. Before writing UI code, read node_modules/@zenoxai/ui/ai/USAGE.md; for props, node_modules/@zenoxai/ui/ai/components.md. Do not create a component the library already has. ``` Because the files come from the installed version, an agent never reads documentation for a newer API than the one the app has. ## On this site | URL | What | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | [/llms.txt](/llms.txt) | An index of every page, following the [llms.txt](https://llmstxt.org) convention. | | [/llms-full.txt](/llms-full.txt) | Every page in one file. | | Any page + `.md` | The Markdown twin of the page, with each live example replaced by its full source. For example [/components/button.md](/components/button.md). | | [/registry.json](/registry.json) | The component registry of the documented version. | ## In this repository `AGENTS.md` at the root is the source of truth for conventions, commands and what never to do. `packages/ui/AGENTS.md` and `apps/docs/AGENTS.md` add the rules for each package, and `CLAUDE.md` imports the root file for Claude Code. The checks that matter run locally with `pnpm check` and are enforced in CI, so an agent gets the same feedback a person does: lint rules for tokens and layers, browser tests, axe on every docs page, and commitlint on every commit. ## Keeping it accurate - Props are documented once, with JSDoc in the component source. The registry, the API tables on this site and `components.md` all come from it. - `USAGE.md` is checked by a test: every module must appear in its table. - Token tables on the foundations pages are read from `theme.css` at build time. --- # Messages and feedback Which component tells the user what, from field errors to toasts, banners and confirmations. Messages guide the user, confirm what happened and help them recover. They also interrupt, so every message has to earn its place. ## Principles 1. **First, try to remove the need for the message.** A disabled button with a tooltip, a sensible default or a clearer label often does more than an error after the fact. 2. **Interrupt only when the user must decide.** Everything else is shown without stopping their work. 3. **Help the user recognize, understand and fix the problem.** Say what happened and what to do, next to where it happened. 4. **Never rely on color alone.** Pair status colors with text, and with an icon where space allows. ## Choosing the component | Situation | Component | Interrupts | | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------- | | A field value is wrong or missing | `FieldError` under the field, `aria-invalid` on the control ([Field](/components/field)) | No | | An action finished | `toast.success()` ([Toaster](/components/sonner)) | No | | An action failed and the user can carry on | `toast.error()` with what to do | No | | The page cannot work as expected (partial data, delayed sync, a missing integration) | [Alert](/components/alert) or [Info Banner](/patterns/info-banner), inline at the top of the affected area | No | | Context the user should know before acting | [Info Banner](/patterns/info-banner) (`role="note"`) | No | | An action is destructive or cannot be undone | `confirm()` ([Confirm Dialog](/patterns/confirm-dialog)) | Yes | | Leaving would lose unsaved changes | `confirm()` with Discard as the action | Yes | | A list or card has nothing to show | [List Empty State](/patterns/list-empty-state) | No | | A list or card failed to load | [List Error State](/patterns/list-error-state) | No | | Something is loading | [Skeleton](/components/skeleton) when the layout is known, [Spinner](/components/spinner) in a button or a small area | No | | Several items were processed | See [Several items](#several-items) | Depends | ## Field validation Validate when the user leaves a field and again on submit, not on every keystroke. Put the error under the field it belongs to, mark the control with `aria-invalid`, and move focus to the first invalid field on submit. The message says how to fix the value: "Enter a domain, such as acme.com.", not "Invalid input." ```tsx import { Field, FieldError, FieldLabel } from "@zenoxai/ui/components/field" import { Input } from "@zenoxai/ui/components/input" function FieldInvalid() { return ( Monitored domain Enter a domain without the protocol, such as acme.com. ) } ``` ## Toasts A toast confirms the result of something the user just did, and goes away on its own. - One toast per action. A bulk action raises one toast with the count, not one per item. - Success toasts are short past-tense statements: "Rule created". - Use the toast type that matches the result: `toast.success()`, `toast.info()`, `toast.warning()`, `toast.error()`. Each takes the colors and icon of its type; a plain `toast()` is neutral. - Do not put the only copy of an important error in a toast: it disappears. If the user must act on it, show it inline or in a dialog. - Offer at most one action in a toast, such as Undo or View, and give that toast at least 10 seconds (`duration: 10000`) so the user can read it and reach the button. Toasts pause while the pointer is over them. ```tsx import { toast } from "sonner" import { Button } from "@zenoxai/ui/components/button" function ToasterTypes() { return ( <> ) } ``` ## Long-running work Scans, exports and imports can take minutes. Never hold the user in a modal or a page-wide spinner for them. 1. Start the work and confirm it at once: `toast("Export started")`. 2. Show the progress where the result will appear: a "Running" status on the row, a [Progress](/components/progress) bar in the card. 3. When it finishes, say so with an action that leads to the result: "Export ready" with Download, "Scan finished" with View results. 4. When it fails, `toast.error()` with what to do, and keep the failed state visible where the result would have been, so the user finds it after the toast is gone. ## Inline messages Use an [Alert](/components/alert) for a message with a title and details, and an [Info Banner](/patterns/info-banner) for a single line. Place it at the top of the area it describes, not at the top of the page when only one card is affected. An inline message stays until the condition behind it is gone; do not make the user dismiss information that is still true. ```tsx import { InfoBanner } from "@zenoxai/ui/patterns/info-banner" function InfoBannerTones() { return (
Results include 2 of 3 tenants you can access. All connectors reported in the last hour. The CrowdStrike sync is delayed. Data may be 20 minutes old. This report is a snapshot from October 1 and does not update. Exports are disabled until the storage quota is raised.
) } ``` ## Confirmations Ask for confirmation only before an action that destroys data, cannot be undone or affects other people. Asking before routine actions teaches users to confirm without reading. - The title asks the question and names the object: "Delete asset vpn-01?" - The body says what else changes and whether it can be undone. - The confirm button repeats the verb ("Delete asset") with the `destructive` variant; the other button is Cancel. - For the most destructive actions on a named resource, such as deleting a tenant, ask the user to type its name. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogDestructive() { const [result, setResult] = React.useState(null) async function deleteReport() { const confirmed = await confirm({ title: "Delete this report?", description: "Monthly exposure report, September 2026 will be deleted for everyone in the tenant. This cannot be undone.", confirmLabel: "Delete report", confirmVariant: "destructive", }) setResult(confirmed) } return (

{result === null ? "Not asked yet." : `confirm() resolved to ${result}.`}

) } ``` ## Several items When an action runs on many selected items, report the result once: | Result | Feedback | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Every item succeeded | A success toast with the count: "12 alerts resolved" | | The action applies to none of the items | Disable the action before it runs; the tooltip says why | | Some items cannot take the action | Before running, a confirmation with the counts and the reason, offering to run on the rest | | Some items failed while running | A dialog that summarizes the result ("9 of 12 alerts resolved"), then lists each failed item with its reason, and a Close button | Keep the items selected afterwards, so the user can act on what failed. ## Writing the message The words matter as much as the component. Follow [Writing](/guidelines/writing#messages): what happened, why when it helps, what to do; no blame; no raw error messages; no "successfully". --- # Radius and spacing The corner radius scale and the spacing conventions of a dense interface. ## Radius Every radius derives from one variable, `--radius` (0.375rem, 6px), smaller than the shadcn default for the flatter look of an operations console. | Utility | Value | At the default radius | | --- | --- | --- | | `rounded-xs` | `calc(var(--radius) * 0.25)` | 1.5px | | `rounded-sm` | `calc(var(--radius) * 0.6)` | 3.6px | | `rounded-md` | `calc(var(--radius) * 0.8)` | 4.8px | | `rounded-lg` | `var(--radius)` | 6px | | `rounded-xl` | `calc(var(--radius) * 1.4)` | 8.4px | | `rounded-2xl` | `calc(var(--radius) * 1.8)` | 10.8px | | `rounded-3xl` | `calc(var(--radius) * 2.2)` | 13.2px | | `rounded-4xl` | `calc(var(--radius) * 2.6)` | 15.6px | - Controls (buttons, inputs, selects) use `rounded-lg`. - Cards, popovers, menus and table containers use `rounded-md` or `rounded-lg`. - Dialogs use `rounded-xl`. - Pills (`rounded-full`) are for avatars, dots and badges with `pill`. - When nesting rounded elements, the inner radius is the outer radius minus the padding between them, or the corners look uneven. ## Spacing Spacing uses the Tailwind 4px scale. The density of the platform comes from a few fixed steps: | Element | Value | | ---------------------------------- | ------------------------------------------------ | | Control height (default / sm / xs) | 32px / 28px / 24px (`h-8`, `h-7`, `h-6`) | | Table row | 44px for virtualized tables; cells `px-3 py-1.5` | | Card padding | 16px, 12px for `size="sm"` | | Page gutter | 16px (`px-4`) | | Gap between related controls | 6px to 8px (`gap-1.5`, `gap-2`) | | Gap between sections | 24px to 32px (`gap-6`, `gap-8`) | Use the scale, never an arbitrary pixel value, for spacing. Arbitrary values are acceptable only for structural math (`calc(100vh-3.5rem)`) and fixed widths of a specific layout. --- # Data display How Zenox shows tables, identifiers, times, empty values and severity, the content analysts read all day. Most Zenox screens are lists of alerts, events, assets and findings. Analysts scan them for hours, compare them with other tools and cite them in reports, so these rules favor values that are exact, aligned and comparable over values that are short or friendly. ## Tables ```tsx import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table" import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge" function TableDemo() { const alerts = [ { id: "ALR-4821", title: "Impossible travel sign-in", asset: "m.rocha@acme.com", severity: "critical", events: 3, }, { id: "ALR-4820", title: "Outbound traffic to a known C2 domain", asset: "srv-web-02", severity: "high", events: 1284, }, { id: "ALR-4817", title: "RDP exposed to the internet", asset: "vm-jump-01", severity: "high", events: 57, }, { id: "ALR-4815", title: "TLS certificate expires in 5 days", asset: "portal.acme.com", severity: "medium", events: 1, }, { id: "ALR-4809", title: "Unsigned PowerShell script ran", asset: "wks-0193", severity: "low", events: 12, }, ] as const return ( Open alerts, most recent first. ID Alert Asset Severity Events {alerts.map((alert) => ( {alert.id} {alert.title} {alert.asset} {alert.events.toLocaleString("en-US")} ))}
) } ``` ### Alignment | Column holds | Align | Notes | | -------------------------------------------------- | ------------------------- | --------------------------------------------------------- | | Text, names, titles | Left | | | Identifiers: IDs, hashes, IPs, hosts, e-mails | Left | `font-mono` | | Numbers you compare: counts, sizes, scores, prices | Right | `tabular-nums`, so digits line up | | Dates and times | Left | One format per column, so the values line up by length | | Severity, status | Left | A badge with its label | | Row actions | Right, in the last column | See [Action placement](/guidelines/action-placement#rows) | The header of a column takes the alignment of its values. ### Rows - **One line per row.** Cells do not wrap. Truncate long text at the end (`max-w-*` on the cell, `truncate` and `title` on the text), and show the full value in the item's detail view. - **Default density.** Use the Table's own row height and cell padding. Do not pad cells to make a table look airier: it costs the rows the user came to see. - **Sort by the question the page answers.** Alerts by severity, then newest first; events and audit entries newest first. Show the sort on the column header. - **Keep the header in view.** A long table scrolls inside its own container with a sticky header: `containerClassName="max-h-96"` on Table and `sticky` on TableHeader. - **Selection** is a checkbox in the first column; bulk actions appear in the toolbar (see [Actions on several items](/guidelines/action-placement#actions-on-several-items)). ### Paging Paginate the lists users work through (alerts, assets, findings, audit entries), and show the total: "1 to 50 of 1,204". A page number can be cited, and a position survives a reload. Infinite scroll is for feeds read once, never for lists that are exported, audited or compared. ## Values ### Empty values Show an empty value with [Empty Value](/patterns/empty-value): a muted em dash, with words for screen readers. A blank cell is ambiguous (no value, still loading, or a rendering error), and "N/A", "null" or "-" read as data. ```tsx {asset.owner ?? } ``` This placeholder is the one use of the em dash in the interface; in sentences, the [writing rules](/guidelines/writing#punctuation-and-symbols) apply. When the absence itself means something, say it instead: "Never scanned", "No owner", "Not reported by the agent". ### Identifiers - Hashes, IPs, hosts, domains, keys and IDs use `font-mono` and offer Copy through [Value Actions](/patterns/copy-button) where the user may paste them elsewhere. - In tables, truncate at the end and give the full value in the `title` and through Copy. In detail views, show the whole value; a value longer than the line wraps at any character. - Inside a sentence, follow `zx/inline-code` in [Layout and craft](/guidelines/layout-and-craft). ### Numbers - Format through the app's formatters, in the user's locale: "1,204" in English, "1.204" in Portuguese and Spanish. Never format by hand. - Units take a space ("16 GB", "250 ms"); percentages do not ("98%"). - Compact notation ("1.2K") only in [Stat Tile](/patterns/stat-tile) figures (with the exact value in `exactValue`) and chart axes. Tables show the exact number. - A KPI figure is never colored by its state; a [Stat Tile](/patterns/stat-tile) `tone` marks the line that says why it needs attention. A change against a previous period is a `delta`, colored by whether it is good news, not by its direction. - Booleans are words or a status: "Enabled", "Disabled". Never "true" and "false", and never a check mark alone. ## Dates and times - **Absolute by default**, in the user's time zone, through the app's date formatters. Event, alert and audit lists show the date and the time to the second: analysts correlate events across tools by timestamp. - **Relative times only for freshness**, where the age matters more than the moment: "Synced 5 min ago", "Last seen 2 h ago" in a summary. Put the absolute value in a tooltip. - **Say which time zone** wherever times are compared: in the column header's tooltip, or as the UTC offset in a detail view. Exports and API values use ISO 8601 with the offset. - Follow the locale's clock and order: the formatter writes 24-hour time for Portuguese and Spanish. Never hard-code a date format. ## Severity and status ```tsx import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge" function SeverityBadgeDemo() { return ( <> ) } ``` - **Severity** says how bad something is: Critical, High, Medium, Low, Info. Keep that order everywhere: sorting, filters, legends, charts and counts. Show it with [Severity Badge](/patterns/severity-badge), which pairs the color with the label. - **Status** says where an item is in its workflow: Open, Acknowledged, Resolved. Show it with [Status Badge](/patterns/status-badge), never in severity colors: a resolved critical alert is still critical. - In a fixed layout, such as a row of stat tiles, keep the levels with a count of zero so the positions do not move. In an inline summary, leave them out. --- # Motion Durations and easings for transitions, and how reduced motion is handled. Motion in the platform is functional: it shows where an overlay came from and confirms that something changed. It is never decorative. | Token | Value | | --- | --- | | `--duration-enter` | `200ms` | | `--duration-exit` | `120ms` | | `--duration-fast` | `150ms` | | `--duration-base` | `250ms` | | `--duration-slow` | `400ms` | | `--ease-enter` | `cubic-bezier(0.16, 1, 0.3, 1)` | | `--ease-exit` | `cubic-bezier(0.4, 0, 1, 1)` | ## Overlays Every overlay (dialog, sheet, popover, menu, tooltip) shares one asymmetric timing: it **enters** in `--duration-enter` (200ms) with `ease-enter` (a long ease-out) and **exits** in `--duration-exit` (120ms) with `ease-exit` (a short ease-in). Dismissal feels immediate; arrival is legible. The components wire this up through Base UI's `data-starting-style` and `data-ending-style` attributes. ```tsx className = "transition-opacity duration-(--duration-enter) ease-enter data-starting-style:opacity-0 data-ending-style:duration-(--duration-exit) data-ending-style:ease-exit" ``` ## Everything else Hover, toggles and small reveals use `--duration-fast` (150ms). Larger layout changes such as a collapsing sidebar use `--duration-base` (250ms). Reserve `--duration-slow` (400ms) for content that moves a long distance. ## Reduced motion When the user asks the system for reduced motion, `styles.css` collapses every animation and transition to near zero. Elements still land on their final state, so nothing is left half-transformed. You do not need to handle `prefers-reduced-motion` in components. --- # Empty states What to show when there is nothing to show, and how to write it so the user knows what to do next. An empty state appears whenever a list, table, chart or panel has no content. It is a moment to explain, not a dead end: the user should always learn why the area is empty and what they can do about it. ## Kinds of empty state These look alike and are not. Each needs its own message, and often its own component. | Kind | When | Tell the user | Component | | -------------- | ---------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ | | Nothing yet | First use: no data has arrived or been created | What will appear here, and how to get it there | [List Empty State](/patterns/list-empty-state) with an action | | No results | A search or filter matched nothing | That the filters caused it, and how to widen them | [List Empty State](/patterns/list-empty-state) with Clear filters | | All done | The user cleared a queue | That the work is finished | [List Empty State](/patterns/list-empty-state), no action | | Failed to load | The request failed | That something went wrong and that they can try again | [List Error State](/patterns/list-error-state) | | No permission | The user lacks the permission for this area | That access is restricted and who can grant it | The app's access-denied state on [List Empty State](/patterns/list-empty-state) | | Not contracted | The tenant does not have the module | That the module is not part of their plan | The app's module-unavailable state on [List Empty State](/patterns/list-empty-state) | The last three are not "no data": showing "No alerts yet" for a failed request or a missing permission tells the user something false. ```tsx import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state" function ListEmptyStateDemo() { return ( ) } ``` ## Writing - **Title:** why the area is empty, in one short line, without a period. "No alerts yet", "No assets match these filters". - **Description:** what happens next or what to do, in one to three full sentences, with periods. "New alerts appear here as soon as a detection rule matches." - Name the object ("alerts", "assets"), not "items", "data" or "results" when you can. - Keep it neutral. An empty state is not an error and not a celebration. ## Actions - Offer an action when there is one clear next step: create the first rule, clear the filters, try again. - Use an `outline` button or a link, never the `default` (primary) variant: the empty state is not the main task of the page. - Show the action only if the user has the permission for it. Otherwise explain who can. - One action. Two at most when they are true alternatives (Create rule, Import rules). ```tsx import { FilterXIcon, SearchXIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state" function ListEmptyStateFiltered() { return ( Clear filters } /> ) } ``` ## Errors A failed request uses [List Error State](/patterns/list-error-state): it says the data could not be loaded and offers Try again. Never put the raw error message in it; log it instead. ```tsx import { ListErrorState } from "@zenoxai/ui/patterns/list-error-state" function ListErrorStateDemo() { return {}} /> } ``` ## Size and repetition - In small containers (a card, a sidebar panel), drop the icon and keep only the text. - When several areas of one screen are empty for the same reason (a new tenant, a filter that matches nothing), show one message for the screen instead of one per card. - Keep the table header visible above an empty table, so the user still sees what the table would contain. --- # Icons Lucide icons, their sizes and how to place them in components. The library and every Zenox app use [Lucide](https://lucide.dev) through `lucide-react`, a peer dependency of the package. ```tsx import { BellIcon } from "lucide-react" ``` ## Naming Import the `Icon`-suffixed name (`Trash2Icon`, not `Trash2`). It is the same component, and the suffix makes it obvious in JSX that the element is an icon. ## Size Components size their icons. Inside a button, menu item, badge or input group, an icon without a `size-*` class takes the size the component sets (16px in a default button, 12px in a badge). Give an icon a size only outside a component: | Context | Class | | --------------------------------- | -------------------------------------------------- | | Next to body text, in a dense row | `size-3.5` | | Default | `size-4` | | Page title icon | `size-5` | | Empty states | the [Empty](/components/empty) media slot sizes it | ## Placement in buttons Mark the icon with `data-icon="inline-start"` or `data-icon="inline-end"`. The button tightens its padding on that side so the label stays optically centered. ```tsx ``` ## Accessibility - An icon next to visible text is decorative. Lucide icons render `aria-hidden` by default. - An icon-only button needs an `aria-label` that says what the button does ("Delete alert"), not what the icon shows ("Trash"). - Do not convey state with an icon alone: pair it with text or a tooltip. ## Brand marks The Zenox and Tellyu marks are components, not icons. See [Brand](/foundations/brand). --- # Dark mode How the theme switches, how to force light in a region, and how components follow. ## The contract Dark mode is the `.dark` class on ``. That class is the whole contract between the design system and whatever manages the theme: next-themes, a script, a server-rendered cookie. Every token has a dark value, so components need no `dark:` classes of their own for color. ## With next-themes ```tsx import { ThemeProvider } from "next-themes" ``` `disableTransitionOnChange` stops every transition from animating during the switch. ## Without a provider Set the class before the first paint, so the page never flashes the wrong theme: ```html ``` This site does exactly that. ## Forcing light in a region Mark a subtree `.force-light` and it renders with the light palette even inside `.dark`. The auth screens use it. The `dark:` variant is also disabled inside the region. ```tsx
...
``` ## Reading the theme in JavaScript `useColorScheme()` returns `"light"` or `"dark"` from the class on `` and updates when it changes. The [Toaster](/components/sonner) uses it; use it when a third-party widget needs the theme as a prop. ```tsx import { useColorScheme } from "@zenoxai/ui/hooks/use-color-scheme" const scheme = useColorScheme() ``` ## The `dark:` variant Prefer tokens. Reach for `dark:` only for an adjustment that is not a color role, such as a different opacity on a decorative image. --- # Layout and craft Small rules that keep Zenox interfaces consistent, each with an ID to cite in reviews and prompts. These rules cover the details that separate a consistent interface from one that only uses the right components. Each has an ID: cite it in a review comment or an agent prompt (`zx/heading-case`) instead of explaining it again. ## Rules ### `zx/body-size` Running text is `text-sm` Body copy, buttons, form controls, table cells, help text and descriptions are `text-sm` (12.5px). Larger sizes are for titles and figures only. `text-xs` (11px, the floor) is for captions, badges and axis labels: never a sentence, a description or a code block, and never the only place a user reads something they must act on. ```tsx // Good

Scans run every night at 02:00.

// Avoid

Scans run every night at 02:00.

Scans run every night at 02:00.

``` ### `zx/heading-case` Sentence case for every title Capitalize only the first word and proper nouns. Never set a title in capitals; the caption style is for short group labels only. See [Writing](/guidelines/writing#capitalization). ```tsx // Good // Avoid ``` ### `zx/font-weight` Weight follows the title's role Page and section headings are `font-semibold`; container titles (Card, Dialog, Sheet, Drawer) are `font-medium`, so they stay below the section heading above them; compact 14px titles are `font-semibold`, or they would not stand apart from the medium labels around them. Labels, buttons and emphasis inside text are `font-medium`. Nothing is `font-bold`: even KPI figures in a [Stat Tile](/patterns/stat-tile) are semibold. See [Typography](/foundations/typography#title-levels). ```tsx // Good

Exposure

Microsoft 365 connector

Credentials for acme.com were found.

// Avoid: a container title as heavy as the section heading above it Microsoft 365 connector

Exposure

``` ### `zx/tracking` Leave letter spacing alone Do not change `tracking-*` on running text or titles. The only tracked text is the uppercase caption, which uses `tracking-wider` or `tracking-widest` from the theme. ### `zx/related-spacing` Related text sits closer together A title and its description are closer to each other than to the content that follows them. ```tsx // Good

Detection rules

Rules decide which events become alerts.

// Avoid: the description is as far from its title as from the button

Detection rules

Rules decide which events become alerts.

``` ### `zx/optical-padding` Vertical padding a step smaller than horizontal Text carries its own line height above and below, so a box around text looks balanced with a little less vertical padding than horizontal: `px-4 py-3`, not `p-4`. ### `zx/card-nesting` Never put a card inside a card Group content inside a card with headings, separators or a [Description List](/patterns/description-list). A card within a card doubles the borders and the padding and reads as a mistake. ```tsx // Good Asset ... // Avoid ... ``` ### `zx/border-or-shadow` A raised surface gets a ring, not a border and a shadow Floating surfaces (popovers, menus, dialogs) use a shadow with a translucent `ring`, as the library components do. A solid border under a shadow looks heavy. Surfaces on the page (cards, tables) use a border and no shadow. ```tsx // Good
...
// Avoid
...
``` ### `zx/concentric-radius` Nested corners are concentric When a rounded element sits inside another with little padding between them, the inner radius is the outer radius minus that padding. Equal radii make the corners look uneven. ```tsx // Good
...
// Avoid
...
``` ### `zx/icon-alignment` Icons align with the first line of text When text next to an icon can wrap, align the icon with the first line, not the middle of the block. Wrap it in an element one line tall. ```tsx // Good

A message that may wrap onto a second line.

// Avoid

A message that may wrap onto a second line.

``` ### `zx/inline-code` Identifiers in running text are monospace and slightly smaller Hashes, IPs, file names and keys inside a sentence use `font-mono` at about 90% of the text size, so they do not look bigger than the words around them. Standalone values in tables and detail views use `font-mono` at the normal size. ### `zx/sticky-border` Sticky bars have a border A header or toolbar that stays in place while content scrolls under it gets a `border-b` (or `border-t` at the bottom), so the edge stays visible against the moving content. ### `zx/overlay-state` Never render an overlay conditionally Keep dialogs, sheets and popovers mounted and control them with `open`. Rendering them conditionally skips the exit animation and loses focus restoration. ```tsx // Good ... // Avoid {open ? ... : null} ``` ### `zx/collapse-size` Content keeps its size while it collapses When a panel animates closed, the content inside keeps its width or height and is clipped, rather than reflowing as the panel shrinks. ### `zx/heading-action` An action beside a heading is centered on it The action of a page header, a section heading or a card header sits on the right, vertically centered on the heading block: the title, or the title and its description together. [Section Heading](/patterns/section-heading) and `CardAction` do it for you; a hand-built header uses `items-center`, not `items-start`. ### `zx/control-height` Controls in one row share one size Buttons, inputs, selects, toggle groups and filter controls placed side by side use the same `size`, so they share one height: 28px (`sm`), 32px (`default`) or 36px (`lg`). A toolbar of mixed heights looks broken even when each control is right on its own. ```tsx // Good ... // Avoid: 32px input, 28px toggle group, 32px button ... ``` ### `zx/selected-state` Selected, hovered and checked look different | State | Treatment | | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Hover on a control, a row or a navigation item | `bg-surface-muted` | | Highlighted item of a menu, command list or listbox: the item Enter would choose | The selection colors below | | Selected or pressed: a pressed toggle, a segmented choice, an active filter chip, the current sidebar item, tab | `bg-primary-soft` with `text-primary-soft-foreground`, and a primary border or underline where it has one | | Checked form controls and chosen values: checkbox, radio, switch, the selected calendar day | The primary fill | Use `text-primary-soft-foreground`, not `text-primary`, on `bg-primary-soft`: only the first reaches 4.5:1 in both themes. A selected item never looks like a hovered one: where an item can be both (a sidebar, a toggle), hover stays `bg-surface-muted`. ### `zx/one-primary` One filled button per view See [Action placement](/guidelines/action-placement#principles). ### `zx/color-not-alone` State is never shown by color alone Every status, severity and validation color comes with text, and with an icon where space allows. --- # Brand The Zenox and Tellyu marks, their colors and how to place them. The marks ship as React components in [Logo](/components/logo). They are the official artwork, inlined as SVG that paints in `currentColor`, so one asset serves a light header, the dark topbar and a hover state. ```tsx import { TellyuMark, ZenoxMark, ZenoxWordmark } from "@zenoxai/ui/components/logo" function BrandMarks() { return (
) } ``` ## The marks | Component | What | Use | | --------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `ZenoxWordmark` | The full ZENOX lockup, with the signal fan as the O. About 4.9:1. | Headers, sign-in, documents. Size it by height (`h-5`). | | `ZenoxMark` | The signal fan alone. | Collapsed sidebars, loaders, favicon-sized slots. | | `TellyuMark` | The mark of Tellyu, the AI layer of the platform. | Wherever Tellyu speaks: assistant messages, AI-generated content. Never in place of the Zenox mark. | ## Colors The marks default to `text-brand-mark`, so they need no color class in most places. | Context | Class | Light | Dark | | ------------------------------------------------ | --------------------- | ----------------------- | ------- | | Default, on any surface | `text-brand-mark` | #1f5b85, the brand blue | #ffffff | | On the topbar and other dark bands in light mode | `text-topbar-fg` | #fcfcfc | #fcfcfc | | Monochrome, low emphasis | `text-text-secondary` | #4a5462 | #a8acb3 | In dark mode the marks are white, never the lighter action blue (`text-primary` is #4a9dd4 there). The same applies to anything that stands for the brand rather than for an action: a product icon next to the mark, a brand illustration. Interactive elements keep `text-primary`. The light brand blue, `--brand-accent` (#acdffe), is an accent for illustrations and highlights. Do not set text in it on a light surface: it does not reach readable contrast. ```tsx import { ZenoxMark } from "@zenoxai/ui/components/logo" function BrandOnTopbar() { return (
ZenoX Vydar CTI
) } ``` ## Rules - Do not redraw, stretch, rotate or recolor parts of a mark. Size it with one class. - Leave clear space around a mark at least as wide as the fan's height. - Give the mark an accessible name with `title` when it is the only thing that names the product (a home link with no text). Omit it when visible text beside the mark already does. - Product names are text, not artwork: write "Vydar" beside the Zenox mark in the interface font. --- # Accordion A stack of headings that each expand to reveal a section of content. ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@zenoxai/ui/components/accordion" function AccordionDemo() { return ( What happened

A lookalike domain, acme-login.co, was registered on 21 October and serves a copy of the Acme sign-in page.

Who is affected

Employees and customers who sign in at acme.com. No credentials have been observed in leak sources yet.

Recommended action

Request a takedown from the registrar and add the domain to the email gateway block list.

) } ``` ## Usage ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@zenoxai/ui/components/accordion" ``` ```tsx What happened A lookalike domain was registered. ``` Use an accordion to shorten a long page of secondary detail: alert explanations, remediation steps, settings grouped by topic. The value is always an array, even when only one item can be open. Give every item a stable `value` so you can open it from `defaultValue` or control it with `value` and `onValueChange`. Do not hide information the user needs to act on. For a single show/hide region, use [Collapsible](/components/collapsible). To switch between views of equal weight, use [Tabs](/components/tabs). ## Examples ### Multiple open items By default, opening an item closes the others. Set `multiple` to let several stay open, for example when the user compares groups of findings. ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@zenoxai/ui/components/accordion" function AccordionMultiple() { return ( Web servers (12)

4 hosts expose TLS 1.0. 2 certificates expire within 30 days.

Mail servers (3)

SPF and DMARC are configured. DKIM is missing on mx2.acme.com.

DNS servers (2)

No findings in the last scan.

) } ``` ### Disabled item Disable an item whose content is not ready yet. Say why in the trigger label. ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@zenoxai/ui/components/accordion" function AccordionDisabledItem() { return ( Detection rule

Typosquatting, edit distance 2 from a monitored domain.

Evidence (processing)

Screenshots and WHOIS records.

) } ``` ## Accessibility - Each trigger is a button inside a heading, with `aria-expanded` and `aria-controls` pointing to its panel. Arrow keys move focus between triggers; Enter and Space toggle the item. - The heading is always an `

`. Place the accordion where an `

` fits the outline of the page, under an `

`. - Closed panels are unmounted by default, so the browser's find-in-page skips them. Set `hiddenUntilFound` on `Accordion` when users need to search the hidden text. ## API reference `import { ... } from "@zenoxai/ui/components/accordion"` ### Accordion | Prop | Type | Default | Description | | --- | --- | --- | --- | | defaultValue | `AccordionValue` | | The uncontrolled value of the item(s) that should be initially expanded. To render a controlled accordion, use the `value` prop instead. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | hiddenUntilFound | `boolean` | `false` | Allows the browser's built-in page search to find and expand the panel contents. Overrides the `keepMounted` prop and uses `hidden="until-found"` to hide the element without removing it from the DOM. | | keepMounted | `boolean` | `false` | Whether to keep the element in the DOM while the panel is closed. This prop is ignored when `hiddenUntilFound` is used. | | loopFocus | `boolean` | | Deprecated following the [APG guidance update](https://github.com/w3c/aria-practices/pull/3434) to remove roving focus. This prop no longer affects keyboard focus behavior. @deprecated | | multiple | `boolean` | `false` | Whether multiple items can be open at the same time. | | onValueChange | `((value: AccordionValue, eventDetails: AccordionRootChangeEventDetails) => void)` | | Event handler called when an accordion item is expanded or collapsed. Provides the new value as an argument. | | orientation | `"horizontal" \| "vertical"` | `'vertical'` | Deprecated following the [APG guidance update](https://github.com/w3c/aria-practices/pull/3434) to remove roving focus. This prop no longer affects keyboard focus behavior. @deprecated | | render | `ReactElement> \| ComponentRenderFn>` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | value | `AccordionValue` | | The controlled value of the item(s) that should be expanded. To render an uncontrolled accordion, use the `defaultValue` prop instead. | ### AccordionItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | onOpenChange | `((open: boolean, eventDetails: AccordionItemChangeEventDetails) => void)` | | Event handler called when the panel is opened or closed. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | value | `any` | | A unique value that identifies this accordion item. If no value is provided, a unique ID will be generated automatically. Use when controlling the accordion programmatically, or to set an initial open state. @example ```tsx // initially open // initially closed ``` | ### AccordionTrigger | Prop | Type | Default | Description | | --- | --- | --- | --- | | nativeButton | `boolean` | `true` | Whether the component renders a native ` ) } ``` ## With a link Links in the title and description are underlined. Use a link when the fix lives on another page; use `AlertAction` when the user can fix it from here. ```tsx import { TriangleAlertIcon } from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@zenoxai/ui/components/alert" function AlertWithLink() { return ( 12 assets have no owner Alerts on these assets are not routed to anyone.{" "} Assign owners to start routing them. ) } ``` ## Accessibility - `Alert` renders `role="alert"`, an assertive live region: screen readers interrupt the user to read it when it appears. That fits a failure or a risk that shows up while the user works. For a message that is not urgent, override the role: `role="status"` when it appears after an action, `role="note"` when it is part of the page from the first render. - The icon is decorative. The title must carry the meaning on its own; never rely on the color to tell a warning from an error. ## API reference `import { ... } from "@zenoxai/ui/components/alert"` ### Alert | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `"default" \| "destructive" \| "info" \| "success" \| "warning" \| "neutral" \| null` | | Tone of the message. `default` is a plain card; `info`, `success`, `warning` and `destructive` tint the border, fill and text; `neutral` is a muted gray box. | ### AlertTitle No props of its own. ### AlertDescription No props of its own. ### AlertAction No props of its own. --- # Alert Dialog A dialog that interrupts the user to confirm an irreversible or consequential action. ```tsx import * as React from "react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@zenoxai/ui/components/alert-dialog" import { Button } from "@zenoxai/ui/components/button" function AlertDialogDemo() { const [open, setOpen] = React.useState(false) return ( }> Delete rule Delete detection rule? “Impossible travel” stops raising alerts in every tenant. Alerts it already raised stay in the queue. Cancel setOpen(false)} > Delete rule ) } ``` ## Usage ```tsx import * as React from "react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@zenoxai/ui/components/alert-dialog" import { Button } from "@zenoxai/ui/components/button" function DeleteRuleButton() { const [open, setOpen] = React.useState(false) return ( }> Delete rule Delete detection rule? It stops raising alerts in every tenant. Cancel { await deleteRule() setOpen(false) }} > Delete rule ) } ``` Use an alert dialog before an action the user cannot undo or would not expect: deleting a rule, revoking a key, archiving alerts in bulk. It asks one question and offers two answers. Unlike [Dialog](/components/dialog), a click outside does not close it: the user has to choose. - For a task with fields (rename, create, edit), use [Dialog](/components/dialog). - For an action that is easy to undo, skip the confirmation: run it and offer "Undo" in a [toast](/components/sonner). - To ask for confirmation from an event handler with one call (`await confirm({...})`), use the [Confirm Dialog](/patterns/confirm-dialog) pattern, which is built on this component. `AlertDialogCancel` closes the dialog. `AlertDialogAction` is a plain `Button`: it runs your `onClick` and does not close the dialog, so control `open` and close it when the action is done. Write the title as the question ("Delete detection rule?") and repeat the verb on the action button ("Delete rule"), never "OK" or "Yes". ## Examples ### With an icon `AlertDialogMedia` holds an icon above the title, or beside it from the `sm` breakpoint up. ```tsx import * as React from "react" import { KeyRoundIcon } from "lucide-react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger } from "@zenoxai/ui/components/alert-dialog" import { Button } from "@zenoxai/ui/components/button" function AlertDialogWithMedia() { const [open, setOpen] = React.useState(false) return ( }> Revoke API key Revoke API key? Integrations that use “siem-export” stop sending events immediately. You cannot restore a revoked key. Keep key setOpen(false)} > Revoke key ) } ``` ### Small `size="sm"` narrows the dialog, centers the text and splits the footer into two equal buttons. Use it for one-line questions. ```tsx import * as React from "react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@zenoxai/ui/components/alert-dialog" import { Button } from "@zenoxai/ui/components/button" function AlertDialogSmall() { const [open, setOpen] = React.useState(false) return ( }> Sign out everywhere Sign out of all sessions? You will need to sign in again on every device. Cancel setOpen(false)}> Sign out ) } ``` ### Pending action When the action calls the server, keep the dialog open until the request finishes. Disable both buttons, show a `Spinner` on the action, and ignore close requests in `onOpenChange` while it runs. ```tsx import * as React from "react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@zenoxai/ui/components/alert-dialog" import { Button } from "@zenoxai/ui/components/button" import { Spinner } from "@zenoxai/ui/components/spinner" function AlertDialogPending() { const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState(false) function archive() { setPending(true) // Stands in for the request that archives the alerts. setTimeout(() => { setPending(false) setOpen(false) }, 1200) } return ( { if (!pending) setOpen(next) }} > }> Archive 12 alerts Archive 12 alerts? Archived alerts leave the queue and stop counting toward the SLA. You can restore them from the archive for 90 days. Cancel {pending && } {pending ? "Archiving" : "Archive alerts"} ) } ``` ## Accessibility - The popup has `role="alertdialog"`. Focus moves into it on open, stays inside and returns to the trigger on close. Escape closes it; a click on the backdrop does not. - `AlertDialogTitle` names the dialog and `AlertDialogDescription` describes it. Render both: the description is where the consequence is spelled out. ## API reference `import { ... } from "@zenoxai/ui/components/alert-dialog"` ### AlertDialog | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the alert dialog after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the alert dialog completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the alert dialog imperatively when called. | | defaultOpen | `boolean` | `false` | Whether the dialog is initially open. To render a controlled dialog, use the `open` prop instead. | | defaultTriggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open dialog. | | handle | `AlertDialogHandle` | | A handle to associate the alert dialog with a trigger. If specified, allows external triggers to control the alert dialog's open state. Can be created with the AlertDialog.createHandle() method. | | onOpenChange | `((open: boolean, eventDetails: AlertDialogRootChangeEventDetails) => void)` | | Event handler called when the alert dialog is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the dialog is opened or closed. | | open | `boolean` | | Whether the dialog is currently open. | | triggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `open` prop to create a controlled dialog. There's no need to specify this prop when the dialog is uncontrolled (that is, when the `open` prop is not set). | ### AlertDialogAction | Prop | Type | Default | Description | | --- | --- | --- | --- | | focusableWhenDisabled | `boolean` | `false` | Whether the button should be focusable when disabled. | | nativeButton | `boolean` | `true` | Whether the component renders a native ` } ``` ## Usage ```tsx import { Button } from "@zenoxai/ui/components/button" ``` Use one `default` (primary) button per view for the main action. Everything else is `outline`, `secondary` or `ghost`. Write labels as a verb plus an object ("Save changes", "Delete alert"), in sentence case. ## Variants ```tsx import { Button } from "@zenoxai/ui/components/button" function ButtonVariants() { return ( <> ) } ``` | Variant | Use for | | ------------- | -------------------------------------------------------------- | | `default` | The primary action of the view. One per view. | | `secondary` | A supporting action next to the primary one. | | `outline` | Actions in toolbars, filters and dialogs. | | `ghost` | Low-emphasis actions in dense areas: table rows, icon buttons. | | `destructive` | Irreversible actions. Confirm them with `confirm()` first. | | `link` | An action that reads as inline text. | ## Sizes `default` (32px) is the size for forms and toolbars. `sm` (28px) and `xs` (24px) fit dense tables and filter bars; `lg` (36px) is for empty states and landing surfaces. Input, Select, Native Select, Number Input, Input Group and Combobox share the `sm`, `default` and `lg` heights, so a button and a field of the same size line up. The icon follows the size name: 12px at `xs`, 14px at `sm`, 16px at `default` and `lg`, in the square `icon-*` sizes too. ```tsx import { Button } from "@zenoxai/ui/components/button" function ButtonSizes() { return ( <> ) } ``` ## With an icon Mark the icon with `data-icon="inline-start"` or `data-icon="inline-end"` so the button tightens the padding on that side. An icon-only button uses an `icon*` size and must have an `aria-label`. ```tsx import { ArrowRightIcon, PlusIcon, Trash2Icon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" function ButtonWithIcon() { return ( <> ) } ``` ## Loading Disable the button, set `aria-busy="true"` and put a `Spinner` in place of the icon while the action runs. A disabled button is dimmed to 50%; a busy one is not, so it reads as working rather than unavailable. Keep the label: it tells the user what is in progress. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Spinner } from "@zenoxai/ui/components/spinner" function ButtonLoading() { return ( ) } ``` ## As a link Navigation uses a link, not a button. To get the button look on a link, give the `` the classes from `buttonVariants()`. Do not render an `` through Button's `render` prop: Base UI then gives it `role="button"`, and screen readers announce a button that navigates. ```tsx import { buttonVariants } from "@zenoxai/ui/components/button" function ButtonAsLink() { return ( Read the usage notes ) } ``` ## Accessibility - A disabled button is removed from the tab order. Set `focusableWhenDisabled` when the user must still be able to reach it, for example to read a tooltip that explains why. - `buttonVariants()` returns the classes alone. Use it to give a link the button look; the element keeps its own role. ## API reference `import { ... } from "@zenoxai/ui/components/button"` ### Button | Prop | Type | Default | Description | | --- | --- | --- | --- | | focusableWhenDisabled | `boolean` | `false` | Whether the button should be focusable when disabled. | | nativeButton | `boolean` | `true` | Whether the component renders a native ` ) } ``` ## Usage ```tsx import { Button } from "@zenoxai/ui/components/button" import { ButtonGroup } from "@zenoxai/ui/components/button-group" export function AlertActions() { return ( ) } ``` Use a button group for actions that act on the same object and belong together, such as the actions on an alert, or to attach a button to an input. The group only changes layout: it squares the inner corners and merges the borders of its direct children. Use something else when: - The buttons hold a selection state (a layout switch, a time range): use a [Toggle Group](/components/toggle-group). - There are more than three or four actions: keep the main one visible and move the rest into a [Dropdown Menu](/components/dropdown-menu). Use the same `variant` and `size` for every button in a group. Mixed heights break the shared borders. ## Examples ### Nested A `ButtonGroup` inside another adds an 8px gap between the inner groups. Use it for toolbars made of several clusters, like pagination. ```tsx import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { ButtonGroup } from "@zenoxai/ui/components/button-group" function ButtonGroupNested() { return ( ) } ``` ### Split button A main action with a menu of variations next to it. `ButtonGroupSeparator` draws the divider between two filled buttons, which have no border of their own. ```tsx import { ChevronDownIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { ButtonGroup, ButtonGroupSeparator } from "@zenoxai/ui/components/button-group" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@zenoxai/ui/components/dropdown-menu" function ButtonGroupSplit() { return ( } > Export as PDF Export as CSV Export as JSON ) } ``` ### With an input An `Input` inside the group stretches to fill the row. `ButtonGroupText` renders a static prefix or suffix, such as a protocol or a unit, with the same borders. ```tsx import { SearchIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { ButtonGroup, ButtonGroupText } from "@zenoxai/ui/components/button-group" import { Input } from "@zenoxai/ui/components/input" function ButtonGroupWithInput() { return (
https://
) } ``` ### Vertical `orientation="vertical"` stacks the children and merges their top and bottom borders, for compact controls such as map zoom. ```tsx import { MinusIcon, PlusIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { ButtonGroup } from "@zenoxai/ui/components/button-group" function ButtonGroupVertical() { return ( ) } ``` ## Accessibility - `ButtonGroup` renders `role="group"`. Give it an `aria-label` when the purpose is not clear from the buttons alone ("Alert actions", "Pagination"). - Every button keeps its own tab stop; the group adds no arrow-key navigation. For a single tab stop with arrow keys, use a [Toggle Group](/components/toggle-group). - Icon-only buttons need an `aria-label`. `ButtonGroupText` is plain text: an input next to it still needs its own label. ## API reference `import { ... } from "@zenoxai/ui/components/button-group"` ### ButtonGroup | Prop | Type | Default | Description | | --- | --- | --- | --- | | orientation | `"horizontal" \| "vertical" \| null` | `horizontal` | `horizontal` (default) joins the items side by side; `vertical` stacks them. | ### ButtonGroupSeparator | Prop | Type | Default | Description | | --- | --- | --- | --- | | orientation | `"horizontal" \| "vertical"` | `vertical` | The orientation of the separator. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### ButtonGroupText | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | --- # Calendar A month grid for picking a single date or a date range. ```tsx import * as React from "react" import { Calendar } from "@zenoxai/ui/components/calendar" function CalendarDemo() { const [date, setDate] = React.useState( new Date(2026, 9, 14) ) return ( ) } ``` ## Usage ```tsx import * as React from "react" import { Calendar } from "@zenoxai/ui/components/calendar" export function ScanDate() { const [date, setDate] = React.useState() return } ``` `Calendar` is [React DayPicker](https://daypicker.dev) styled with the Zenox tokens. It accepts every DayPicker prop: `mode`, `selected`, `onSelect`, `disabled`, `numberOfMonths`, `locale` and the rest. The wrapper adds `buttonVariant`, the [Button](/components/button) variant of the previous and next month buttons (`ghost` by default). The selected day, and the start and end of a range, are filled with the primary color and keep it on hover. The days inside a range sit on the soft selection band (`primary-soft`), hover is the plain `surface-muted` step, and today is marked by its text (primary, semibold) rather than a fill, so the three never look alike. Render the calendar inline when picking a date is the main task of the view, such as scheduling a scan. In a form, open it from a button in a [Popover](/components/popover), so it takes space only while the user picks. For a date the user knows by heart, like a birth date, a typed [Input](/components/input) is faster. ## Examples ### Date range `mode="range"` selects a start and an end date. `selected` is a `{ from, to }` object; `to` is `undefined` until the user picks the second date. Show two months with `numberOfMonths={2}` so most ranges fit without paging. ```tsx import * as React from "react" import { Calendar } from "@zenoxai/ui/components/calendar" function CalendarRange() { const [range, setRange] = React.useState< { from: Date | undefined; to?: Date } | undefined >({ from: new Date(2026, 9, 6), to: new Date(2026, 9, 19) }) return ( ) } ``` ### Disabled days `disabled` takes one or more matchers: a date, a `{ before }` or `{ after }` bound, a `{ from, to }` range, a `{ dayOfWeek }` list or a function. Here a scan can only be scheduled on a weekday from October 12 on. ```tsx import * as React from "react" import { Calendar } from "@zenoxai/ui/components/calendar" function CalendarDisabledDays() { const [date, setDate] = React.useState() return ( ) } ``` ### Month and year dropdowns `captionLayout="dropdown"` replaces the caption with month and year selects, for dates far from today. Bound them with `startMonth` and `endMonth`. ```tsx import * as React from "react" import { Calendar } from "@zenoxai/ui/components/calendar" function CalendarDropdowns() { const [date, setDate] = React.useState(new Date(2026, 2, 3)) return ( ) } ``` ### Date picker A button that shows the chosen date and opens the calendar in a popover. Control the popover with `open` and `onOpenChange` to close it once a date is picked. ```tsx import * as React from "react" import { CalendarIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { Calendar } from "@zenoxai/ui/components/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@zenoxai/ui/components/popover" function CalendarDatePicker() { const [open, setOpen] = React.useState(false) const [date, setDate] = React.useState() return ( } > {date ? date.toLocaleDateString("en-US", { dateStyle: "medium" }) : "Pick a report date"} { setDate(next) setOpen(false) }} defaultMonth={date ?? new Date(2026, 9, 1)} /> ) } ``` ## Accessibility - The month is a grid. Arrow keys move between days, Page Up and Page Down change the month (with Shift, the year), Home and End jump to the start and end of the week, and Enter or Space selects the focused day. - Every day button has a full date as its label ("Wednesday, October 14th, 2026"), and the previous and next buttons are labelled. Pass `locale` (from `react-day-picker/locale`) to translate the labels together with the month and weekday names. - Disabled days stay in the grid but cannot be selected. Explain the rule next to the calendar ("Weekdays only"): the dimmed color alone does not tell the user why. ## API reference `import { ... } from "@zenoxai/ui/components/calendar"` ### Calendar | Prop | Type | Default | Description | | --- | --- | --- | --- | | buttonVariant | `"link" \| "default" \| "destructive" \| "outline" \| "secondary" \| "ghost" \| null` | `ghost` | Button variant of the previous and next month arrows. | ### CalendarDayButton | Prop | Type | Default | Description | | --- | --- | --- | --- | | locale | `Partial` | | Locale of the `data-day` attribute's date; Calendar passes its own. | --- # Card A bordered surface that groups the content and actions of one subject. ```tsx import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@zenoxai/ui/components/card" function CardDemo() { return ( Microsoft 365 connector Collects sign-in and audit logs every 5 minutes.
Last sync
4 minutes ago
Events today
182,340
) } ``` ## Usage ```tsx import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@zenoxai/ui/components/card" export function ConnectorCard() { return ( Microsoft 365 connector Collects sign-in and audit logs every 5 minutes. Last sync 4 minutes ago ) } ``` Use a card for one subject: a connector, a settings group, a chart, a detail panel. The card sets the padding, and its parts read it, so `CardHeader`, `CardContent` and `CardFooter` line up without extra classes. The header draws a divider under itself. To run the content straight on from the title, remove it with `className="border-b-0 pb-0"` on `CardHeader`. Use something else when: - The card would show one figure with a label. Use [StatTile](/patterns/stat-tile). - The content is a row in a list of similar records. Use [Item](/components/item). - A section inside a card or a settings page needs a heading. Use [SectionHeading](/patterns/section-heading). ## With an action `CardAction` puts a control at the top right of the header, aligned with the title. Use it for the one action that applies to the whole card. ```tsx import { PlusIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@zenoxai/ui/components/card" function CardWithAction() { return ( Notification rules Who is paged for each severity.
  • Critical: on-call analyst, by phone
  • High: SOC channel, by email
) } ``` ## With a footer `CardFooter` has a top border and a muted background, and the card drops its bottom padding so the footer sits flush with the edge. Put the form actions there, aligned to the end. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@zenoxai/ui/components/card" function CardWithFooter() { return ( Retention Raw events older than this are deleted every night.

Events are kept for 90 days. Alerts and reports are kept for the life of the tenant.

) } ``` ## Small `size="sm"` reduces the padding from 16px to 12px and moves the title to the compact step (14px, semibold; see [Typography](/foundations/typography#title-levels)). Use it for side panels and grids of small cards. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@zenoxai/ui/components/card" function CardSmall() { return ( Asset owner Marina Rocha Infrastructure team ) } ``` ## Accessibility - `CardTitle` renders an `

`, so cards show up in heading navigation. Inside a section that already has an `

`, render a lower level: `}>`. - `CardDescription` is plain text. When it explains a control inside the card, link the two with `aria-describedby`. ## API reference `import { ... } from "@zenoxai/ui/components/card"` ### Card | Prop | Type | Default | Description | | --- | --- | --- | --- | | size | `"default" \| "sm"` | `default` | `sm` tightens the padding and gaps from 16px to 12px and steps the title down to text-sm, for dense grids. | ### CardHeader No props of its own. ### CardFooter No props of its own. ### CardTitle | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### CardAction No props of its own. ### CardDescription No props of its own. ### CardContent No props of its own. --- # Carousel A row of slides that the user moves through with buttons, swipes or arrow keys. ```tsx import { Card, CardContent } from "@zenoxai/ui/components/card" import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@zenoxai/ui/components/carousel" function CarouselDemo() { const threats = [ { name: "Phishing kits", count: 18 }, { name: "Lookalike domains", count: 42 }, { name: "Leaked credentials", count: 7 }, { name: "Exposed services", count: 23 }, { name: "Fake social profiles", count: 5 }, ] return ( {threats.map((threat, index) => ( {threat.count} {threat.name} ))} ) } ``` ## Usage ```tsx import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@zenoxai/ui/components/carousel" ``` ```tsx ... ... ... ``` Use a carousel when items are browsed one or a few at a time and their order matters little: evidence screenshots, report templates, onboarding cards. It hides most of its content, so do not put information there that the user must see. For a list the user scans or compares, use a grid or a [Scroll Area](/components/scroll-area). The component wraps [Embla Carousel](https://www.embla-carousel.com). Pass Embla options through `opts` and plugins (autoplay, wheel gestures) through `plugins`. From the `md` breakpoint (768px), `CarouselPrevious` and `CarouselNext` sit outside the slides, 48px to each side: leave that room around the carousel, or position them yourself with `className`. On smaller screens they move inside the edges, 8px in, over the slides, so they stay on screen and within reach; change that placement with `max-md:` classes. ## Examples ### Several slides per view Set the width of each slide with a `basis-*` class on `CarouselItem`. With `opts={{ align: "start" }}` the slides align to the start of the row instead of the center. ```tsx import { Card, CardContent } from "@zenoxai/ui/components/card" import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@zenoxai/ui/components/carousel" function CarouselMultiple() { const reports = [ "Weekly exposure", "Monthly exposure", "Brand abuse", "Takedown status", "Executive summary", "Vulnerability trends", ] return ( {reports.map((report, index) => ( {report} ))} ) } ``` ### Reading the position `setApi` hands you the Embla API. Subscribe to its `select` event to show the current slide or to drive your own controls. ```tsx import * as React from "react" import { Card, CardContent } from "@zenoxai/ui/components/card" import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@zenoxai/ui/components/carousel" function CarouselWithApi() { const [api, setApi] = React.useState() const [current, setCurrent] = React.useState(0) const captures = [ "21 Oct, 09:14", "21 Oct, 15:40", "22 Oct, 08:02", "23 Oct, 11:27", ] React.useEffect(() => { if (!api) return const onSelect = () => setCurrent(api.selectedScrollSnap()) api.on("select", onSelect) return () => { api.off("select", onSelect) } }, [api]) return (
{captures.map((capture, index) => ( Captured {capture} ))} Screenshot {current + 1} of {captures.length}
) } ``` ## Accessibility - `Carousel` is a region with `aria-roledescription="carousel"`, and each `CarouselItem` a group with `aria-roledescription="slide"`. Give the carousel an `aria-label` and each slide a position label such as "2 of 5". - Left and Right arrow keys move between slides while focus is inside the carousel. The buttons are disabled at either end, unless `opts={{ loop: true }}`. - The buttons carry the English labels "Previous slide" and "Next slide". Pass a translated `aria-label` when the interface is not in English. - Do not autoplay. If a plugin does, give the user a control to pause it. ## API reference `import { ... } from "@zenoxai/ui/components/carousel"` ### Carousel | Prop | Type | Default | Description | | --- | --- | --- | --- | | opts | `Partial` | | Embla options, such as `loop` or `align`; `axis` follows `orientation`. | | orientation | `"horizontal" \| "vertical"` | `horizontal` | Scroll direction. Defaults to horizontal. | | plugins | `CreatePluginType[]` | | Embla plugins, such as autoplay. | | setApi | `((api: EmblaCarouselType) => void)` | | Receives the Embla API when ready, to drive the carousel from outside. | ### CarouselContent No props of its own. ### CarouselItem No props of its own. ### CarouselPrevious | Prop | Type | Default | Description | | --- | --- | --- | --- | | focusableWhenDisabled | `boolean` | `false` | Whether the button should be focusable when disabled. | | nativeButton | `boolean` | `true` | Whether the component renders a native ` {/* Each item's onSelect navigates or runs its action, then closes. */} No results. setOpen(false)}> Alerts setOpen(false)}> Assets setOpen(false)}> Reports setOpen(false)}> New investigation ) } ``` ### In a popover For a picker attached to a button, such as the assignee of an alert, put a `Command` in a [Popover](/components/popover) with `p-0` on the content. Set `data-checked` on the current item to show a check mark at its end, and close the popover in `onSelect`. When the value belongs to a form field, use a [Combobox](/components/combobox) instead. ```tsx import * as React from "react" import { UserIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@zenoxai/ui/components/command" import { Popover, PopoverContent, PopoverTrigger } from "@zenoxai/ui/components/popover" function CommandInPopover() { const [open, setOpen] = React.useState(false) const [assignee, setAssignee] = React.useState("Marina Santos") const analysts = ["Marina Santos", "Jordan Lee", "Ada Okafor", "Thanh Nguyen"] return ( }> {assignee} No analyst found. {analysts.map((name) => ( { setAssignee(name) setOpen(false) }} > {name} ))} ) } ``` ## Accessibility - The input is a combobox that controls the list. Arrow keys move the highlight, Enter selects and the highlighted item is announced; focus stays in the input. - Give `CommandDialog` a `title` that names its purpose ("Command palette", "Go to page"). It is the dialog's accessible name. - Group headings are announced as the names of their groups. ## API reference `import { ... } from "@zenoxai/ui/components/command"` ### Command No props of its own. ### CommandDialog | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the dialog after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the dialog completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the dialog imperatively when called. | | defaultOpen | `boolean` | `false` | Whether the dialog is initially open. To render a controlled dialog, use the `open` prop instead. | | defaultTriggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open dialog. | | description | `string` | `Search for a command to run` | Accessible description, visually hidden; pass a translated one. | | disablePointerDismissal | `boolean` | `false` | Whether to prevent the dialog from closing on outside presses. For non-modal dialogs, this also prevents the dialog from closing when focus moves outside of it. | | handle | `DialogHandle` | | A handle to associate the dialog with a trigger. If specified, allows external triggers to control the dialog's open state. Can be created with the Dialog.createHandle() method. | | modal | `boolean \| "trap-focus"` | `true` | Determines if the dialog enters a modal state when open. - `true`: user interaction is limited to just the dialog: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. - `'trap-focus'`: focus is trapped inside the dialog, but document page scroll is not locked and pointer interactions outside of it remain enabled. When `modal` is `true` or `'trap-focus'`, render `` inside `` so touch screen readers can escape the popup. | | onOpenChange | `((open: boolean, eventDetails: DialogRootChangeEventDetails) => void)` | | Event handler called when the dialog is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the dialog is opened or closed. | | open | `boolean` | | Whether the dialog is currently open. | | showCloseButton | `boolean` | `false` | Shows the X button at the top-right corner; Escape closes it anyway. | | title | `string` | `Command palette` | Accessible name of the dialog, visually hidden; pass a translated one. | | triggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `open` prop to create a controlled dialog. There's no need to specify this prop when the dialog is uncontrolled (that is, when the `open` prop is not set). | ### CommandInput No props of its own. ### CommandList No props of its own. ### CommandEmpty No props of its own. ### CommandGroup No props of its own. ### CommandItem No props of its own. ### CommandShortcut No props of its own. ### CommandSeparator No props of its own. --- # Confirm Dialog Asks the user to confirm an action before it runs, with one awaited call to confirm(). ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogDemo() { const [result, setResult] = React.useState(null) async function dismissAlert() { const confirmed = await confirm({ title: "Dismiss this alert?", description: "The alert moves to Dismissed and stops notifying the on-call analyst.", confirmLabel: "Dismiss alert", }) setResult(confirmed) } return (

{result === null ? "Not asked yet." : `confirm() resolved to ${result}.`}

) } ``` ## Usage Mount one `ConfirmDialogHost` near the root of the app, next to the `Toaster`: ```tsx import { Toaster } from "@zenoxai/ui/components/sonner" import { ConfirmDialogHost } from "@zenoxai/ui/patterns/confirm-dialog" export function AppProviders({ children }: { children: React.ReactNode }) { return ( <> {children} ) } ``` Then call `confirm()` from any event handler. It opens the dialog and resolves to `true` when the user confirms, `false` when they cancel, press Escape or `onConfirm` throws: ```tsx import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" async function dismissAlert() { const confirmed = await confirm({ title: "Dismiss this alert?", description: "The alert moves to Dismissed and stops notifying the on-call analyst.", confirmLabel: "Dismiss alert", }) if (!confirmed) return // ... } ``` The host takes the default button labels, already translated. Everything else comes with each call. Mount exactly one host: every host renders the same dialog, so a second one opens a second copy on top of the first. This page mounts one host for all of its examples. Ask for confirmation only before actions that are destructive, expensive or hard to undo. For an action that can be undone, run it and offer Undo in a toast. For a task that needs a form, use a [Dialog](/components/dialog). Write the `title` as the question, naming the action and the object ("Delete tenant acme-prod?"), and the `description` as what happens and whether it can be undone. Repeat the verb of the title in `confirmLabel` ("Delete tenant"), never "OK" or "Yes". The full list of options is in the API reference below. A second `confirm()` while a dialog is open cancels the first one, which resolves to `false`. ## Examples ### Destructive Set `confirmVariant: "destructive"` for an action that deletes data or cannot be undone, and say so in the description. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogDestructive() { const [result, setResult] = React.useState(null) async function deleteReport() { const confirmed = await confirm({ title: "Delete this report?", description: "Monthly exposure report, September 2026 will be deleted for everyone in the tenant. This cannot be undone.", confirmLabel: "Delete report", confirmVariant: "destructive", }) setResult(confirmed) } return (

{result === null ? "Not asked yet." : `confirm() resolved to ${result}.`}

) } ``` ### Type to confirm For a destructive action on a named resource, such as a tenant or an integration, pass `inputField.expectedValue`. The dialog shows the value after the field label as a monospace chip on a muted fill, so it reads as the literal to copy (one click selects it whole). Write the label as the instruction without the value, such as "Type the tenant name to confirm". The dialog renders a single-line field and keeps the confirm button disabled until the trimmed input equals the value exactly; Enter then confirms. While `onConfirm` runs, the confirm button shows a spinner and is marked busy. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogTypeToConfirm() { const [result, setResult] = React.useState(null) async function deleteTenant() { const confirmed = await confirm({ title: "Delete tenant acme-prod?", description: "Every asset, alert and report of this tenant will be deleted. This cannot be undone.", confirmLabel: "Delete tenant", confirmVariant: "destructive", inputField: { label: "Type the tenant name to confirm", expectedValue: "acme-prod", }, }) setResult(confirmed) } return (

{result === null ? "Not asked yet." : `confirm() resolved to ${result}.`}

) } ``` ### With an option `checkboxField` adds one checkbox, for a choice that only makes sense at the moment of confirming. The promise resolves to a boolean, so read the checkbox in `onConfirm`. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogWithCheckbox() { const [message, setMessage] = React.useState("Not asked yet.") async function removeAsset() { let deleteHistory = false const confirmed = await confirm({ title: "Remove web-prod-03?", description: "The asset stops being scanned and leaves every report.", confirmLabel: "Remove asset", confirmVariant: "destructive", checkboxField: { label: "Also delete its scan history" }, onConfirm: async ({ checked }) => { deleteHistory = checked }, }) if (!confirmed) setMessage("confirm() resolved to false.") else if (deleteHistory) setMessage("Removed, with its scan history.") else setMessage("Removed. Scan history kept.") } return (

{message}

) } ``` ### Async action Run the action in `onConfirm` to keep the dialog open while it runs. Both buttons are disabled and Escape is ignored until the promise settles. If `onConfirm` throws, the dialog closes and `confirm()` resolves to `false`; report the error from inside `onConfirm`, for example with a toast. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { confirm } from "@zenoxai/ui/patterns/confirm-dialog" function ConfirmDialogAsync() { const [result, setResult] = React.useState(null) async function revokeKey() { const confirmed = await confirm({ title: "Revoke this API key?", description: "Integrations that use the key stop sending events until you add a new one.", confirmLabel: "Revoke key", confirmVariant: "destructive", onConfirm: async () => { // Stands in for the API call. Throw to keep the key and resolve false. await new Promise((resolve) => setTimeout(resolve, 1500)) }, }) setResult(confirmed) } return (

{result === null ? "Not asked yet." : `confirm() resolved to ${result}.`}

) } ``` ## Accessibility - The dialog is an alert dialog: it traps focus, does not close on an outside click, and on close returns focus to the element that had it before, usually the button that called `confirm()`. - The title and description are the dialog's accessible name and description. Write both as full sentences; the title alone must make sense. - While `onConfirm` runs, the buttons are only disabled. If the action takes more than a moment, report progress or the result with a toast so screen reader users hear it. ## API reference `import { ... } from "@zenoxai/ui/patterns/confirm-dialog"` ### ConfirmDialogHost | Prop | Type | Default | Description | | --- | --- | --- | --- | | cancelLabel | `string` | `Cancel` | Already-translated default for the cancel button. | | confirmLabel | `string` | `Confirm` | Already-translated default for the confirm button. | ### useConfirmDialog No props of its own. ### confirm | Prop | Type | Default | Description | | --- | --- | --- | --- | | cancelLabel | `string` | | | | checkboxField | `ConfirmCheckboxField` | | | | confirmLabel | `string` | | | | confirmVariant | `"link" \| "default" \| "destructive" \| "outline" \| "secondary" \| "ghost"` | | Variant of the confirm button. Use `destructive` for irreversible actions. | | description (required) | `string` | | | | inputField | `ConfirmInputField` | | | | onConfirm | `((input: { text: string; checked: boolean; }) => Promise)` | | Runs while the dialog shows a pending state. If it throws, the dialog closes and the promise resolves to false; report the error from here. | | title (required) | `string` | | | --- # Context Menu A menu of actions for an element, opened with a right-click or a long press. ```tsx import { CopyIcon, EyeIcon, Trash2Icon, UserPlusIcon } from "lucide-react" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger } from "@zenoxai/ui/components/context-menu" function ContextMenuDemo() { return ( Right-click this alert View details Assign to me A Copy alert ID ⌘C Delete alert ) } ``` ## Usage ```tsx import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@zenoxai/ui/components/context-menu" ``` ```tsx {/* the alert row, card or chart */} View details Assign to me ``` A context menu opens at the pointer when the user right-clicks the trigger area, or long-presses it on a touch screen. It is a shortcut for people who know it is there: a faster way to reach the actions of a table row, an asset card or a chart. - Every action in a context menu must also be reachable from a visible control, usually a [Dropdown Menu](/components/dropdown-menu) on the same row. Keyboard users and many touch users never open a context menu. - For the menus at the top of an editor or app, use [Menubar](/components/menubar). `ContextMenuTrigger` renders a `
` around the area that responds to the right-click and disables text selection inside it. The menu offers the same items as [Dropdown Menu](/components/dropdown-menu): items with icons and shortcuts, a destructive variant, checkbox and radio items, labels, separators and submenus. ## Examples ### Submenu `ContextMenuSub` nests a second menu that opens to the side. ```tsx import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from "@zenoxai/ui/components/context-menu" function ContextMenuSubmenu() { return ( Right-click srv-db-01.prod Open asset Run vulnerability scan Move to tenant Acme Corp Globex Initech Remove from inventory ) } ``` ### Checkbox and radio items Settings of the element under the pointer, such as the display options of a chart, fit checkbox and radio items. They keep the menu open when toggled. ```tsx import * as React from "react" import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuTrigger } from "@zenoxai/ui/components/context-menu" function ContextMenuSettings() { const [showGrid, setShowGrid] = React.useState(true) const [showLegend, setShowLegend] = React.useState(false) const [range, setRange] = React.useState("7d") return ( Right-click the alerts chart Show grid lines Show legend Time range Last 24 hours Last 7 days Last 30 days ) } ``` ## Accessibility - Inside the open menu, arrow keys move between items, Enter runs the focused item and Escape closes the menu. Opening it from the keyboard depends on the browser and operating system (Shift+F10 or the context menu key), which is why the actions need a visible alternative. - `ContextMenuLabel` is a visual heading and is not announced as the group's name. ## API reference `import { ... } from "@zenoxai/ui/components/context-menu"` ### ContextMenu | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the menu after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the menu completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the menu imperatively when called. | | closeParentOnEsc | `boolean` | | @ignore @deprecated This prop has no effect on Context Menu. | | defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | | loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | | onItemHighlighted | `((highlightedItem: HTMLElement, eventDetails: MenuRootHighlightEventDetails) => void)` | | Callback fired when an item is highlighted or unhighlighted. Receives the highlighted item element (or `undefined` if no item is highlighted) and details containing the reason for the change, the event, and the item's text label. The `reason` can be: - `'keyboard'`: the highlight changed due to keyboard navigation. - `'pointer'`: the highlight changed due to pointer hovering. The event may be a `MouseEvent` rather than a `PointerEvent`. - `'none'`: the highlight changed for another reason, such as automatic highlighting while filtering, the item list changing, or the popup opening or closing. | | onOpenChange | `((open: boolean, eventDetails: ContextMenuRootChangeEventDetails) => void)` | | Event handler called when the menu is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the menu is opened or closed. | | open | `boolean` | | Whether the menu is currently open. | | orientation | `"horizontal" \| "vertical"` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | ### ContextMenuTrigger | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### ContextMenuContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | align | `"center" \| "start" \| "end"` | `start` | How to align the popup relative to the specified side. | | alignOffset | `number \| OffsetFunction` | `4` | Additional offset along the alignment axis in pixels. Also accepts a function that returns the offset to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. Defaults to `2` for root context menus when `side` is not specified and `align` is not `'center'`. Otherwise, it defaults to `0`. @example ```jsx { return side === 'top' \|\| side === 'bottom' ? anchor.width : anchor.height; }} /> ``` | | finalFocus | `boolean \| RefObject \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)` | | Determines the element to focus when the menu is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing. | | id | `string` | | @ignore | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | side | `"left" \| "right" \| "top" \| "bottom" \| "inline-end" \| "inline-start"` | `right` | Which side of the anchor element to align the popup against. May automatically change to avoid collisions. Submenus default to `'inline-end'`. | | sideOffset | `number \| OffsetFunction` | `0` | Distance between the anchor and the popup in pixels. Also accepts a function that returns the distance to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. Defaults to `-5` for root context menus when `side` is not specified and `align` is not `'center'`. Otherwise, it defaults to `0`. @example ```jsx { return side === 'top' \|\| side === 'bottom' ? anchor.height : anchor.width; }} /> ``` | ### ContextMenuItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | closeOnClick | `boolean` | `true` | Whether to close the menu when the item is clicked. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | id | `string` | | @ignore | | inset | `boolean` | | Indents the text to line up with items that have a leading icon. | | label | `string` | | Overrides the text used for keyboard text navigation and filtering. Falls back to the rendered text when not provided. | | nativeButton | `boolean` | `true` | Whether the component renders a native ` ) } ``` ## Usage ```tsx import { Button } from "@zenoxai/ui/components/button" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@zenoxai/ui/components/dialog" ``` ```tsx }> Rename asset Rename asset The new name appears in alerts and reports. {/* fields */} }>Cancel ``` Use a dialog for a short, focused task that blocks the page until it is done or dismissed: renaming a record, creating a tenant, reviewing a list before export. The page behind it is inert while it is open. Render the trigger as a `Button` through `render`, so it keeps the button's look and the trigger's behavior. Pick another overlay when the task does not fit that shape: | Need | Use | | ------------------------------------------------------------------------- | ---------------------------------------- | | Confirm an irreversible or consequential action | [Alert Dialog](/components/alert-dialog) | | A long form, or details next to the list that opened them | [Sheet](/components/sheet) | | A panel on touch screens that the user swipes away | [Drawer](/components/drawer) | | A few controls anchored to the button that opened them, page stays usable | [Popover](/components/popover) | `DialogContent` renders the portal, the backdrop and a close button in the top-right corner. Hide the close button with `showCloseButton={false}` only when the footer offers another way out. `DialogFooter` sits flush with the bottom edge on a muted band; pass `showCloseButton` to it to add a "Close" button after its children. ## Examples ### Sizes `size` sets the maximum width from the `sm` breakpoint up: `sm` (25rem, the default), `md` (35rem), `lg` (50rem) and `xl` (68.75rem). Use `sm` for one or two fields, `md` for forms and lists, `lg` and `xl` for tables or side-by-side content. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@zenoxai/ui/components/dialog" function DialogSizes() { return ( <> {(["sm", "md", "lg", "xl"] as const).map((size) => ( }> Open {size} Dialog size {size} The size sets the maximum width from the sm breakpoint up. Below it, every size fills the screen minus a 1rem margin. ))} ) } ``` ### Scrollable content The dialog grows with its content up to the height of the viewport less a 1rem margin on each side, then scrolls as a whole, footer included. To keep the title and the actions in view, give the long part a maximum height and `overflow-y-auto`, and keep the header and footer outside it. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@zenoxai/ui/components/dialog" function DialogScrollable() { return ( }> View findings Weekly vulnerability report 24 findings across 3 tenants, sorted by severity.
    {Array.from({ length: 24 }, (_, index) => (
  • CVE-2024-{3094 + index} srv-web-{String(index + 1).padStart(2, "0")}.prod
  • ))}
) } ``` ### Controlled Control `open` when the dialog must stay open while a request runs and close only when it succeeds. Disable the fields and the buttons while it is pending. ```tsx import * as React from "react" import { Button } from "@zenoxai/ui/components/button" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@zenoxai/ui/components/dialog" import { Field, FieldLabel } from "@zenoxai/ui/components/field" import { Input } from "@zenoxai/ui/components/input" import { Spinner } from "@zenoxai/ui/components/spinner" function DialogControlled() { const [open, setOpen] = React.useState(false) const [saving, setSaving] = React.useState(false) function save() { setSaving(true) // Stands in for the request that saves the tenant. setTimeout(() => { setSaving(false) setOpen(false) }, 1200) } return ( }>New tenant New tenant Tenants isolate assets, alerts and users of one customer. Name } disabled={saving}> Cancel ) } ``` ## Accessibility - Focus moves into the dialog when it opens, stays inside while it is open and returns to the trigger when it closes. Escape and a click on the backdrop close it. - Always render a `DialogTitle`: it is the dialog's accessible name. `DialogDescription` is read after it. If the design has no visible title, keep it and hide it with `className="sr-only"`. - The built-in close button is an icon button labelled "Close" for screen readers. ## API reference `import { ... } from "@zenoxai/ui/components/dialog"` ### Dialog | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the dialog after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the dialog completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the dialog imperatively when called. | | defaultOpen | `boolean` | `false` | Whether the dialog is initially open. To render a controlled dialog, use the `open` prop instead. | | defaultTriggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open dialog. | | disablePointerDismissal | `boolean` | `false` | Whether to prevent the dialog from closing on outside presses. For non-modal dialogs, this also prevents the dialog from closing when focus moves outside of it. | | handle | `DialogHandle` | | A handle to associate the dialog with a trigger. If specified, allows external triggers to control the dialog's open state. Can be created with the Dialog.createHandle() method. | | modal | `boolean \| "trap-focus"` | `true` | Determines if the dialog enters a modal state when open. - `true`: user interaction is limited to just the dialog: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. - `'trap-focus'`: focus is trapped inside the dialog, but document page scroll is not locked and pointer interactions outside of it remain enabled. When `modal` is `true` or `'trap-focus'`, render `` inside `` so touch screen readers can escape the popup. | | onOpenChange | `((open: boolean, eventDetails: DialogRootChangeEventDetails) => void)` | | Event handler called when the dialog is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the dialog is opened or closed. | | open | `boolean` | | Whether the dialog is currently open. | | triggerId | `string \| null` | | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `open` prop to create a controlled dialog. There's no need to specify this prop when the dialog is uncontrolled (that is, when the `open` prop is not set). | ### DialogClose | Prop | Type | Default | Description | | --- | --- | --- | --- | | nativeButton | `boolean` | `true` | Whether the component renders a native ` }> Mark as false positive
) } ``` ## Usage ```tsx import { Button } from "@zenoxai/ui/components/button" import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@zenoxai/ui/components/drawer" ``` ```tsx }> Triage alert Triage alert Brute-force login attempt on vpn-gw-02.prod
{/* body */}
}>Close
``` A drawer is a dialog with gestures. The user can drag it to close it, snap it between heights and stack one on top of another. Use it where people work with their thumbs: mobile layouts, bottom sheets with quick actions, a summary that expands to full height. - On desktop, a panel that slides in from the side and needs no gestures is a [Sheet](/components/sheet). Prefer it there: it is simpler and sized for forms. - For a short task in the middle of the screen, use [Dialog](/components/dialog). - To confirm a destructive action, use [Alert Dialog](/components/alert-dialog). `showSwipeHandle` draws the grab bar. Show it whenever the drawer can be swiped, so the gesture is discoverable. `DrawerFooter` stacks its buttons full width, primary action first. Give the body `p-4`; without a body, the footer keeps the same 1rem gap below the header by itself. A bottom drawer spans the whole viewport width; on wide screens, wrap its content in `mx-auto w-full max-w-md` so lines and buttons stay readable. ## Examples ### Direction `swipeDirection` is the direction that dismisses the drawer, and so the edge it enters from: `down` (the default, a bottom sheet), `up`, `left` or `right`. Side drawers take three quarters of the width on small screens and 24rem from the `sm` breakpoint up. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@zenoxai/ui/components/drawer" function DrawerDirections() { return ( <> {(["up", "right", "down", "left"] as const).map((direction) => ( }> Swipe {direction} {/* flex-1 flex-col keeps the footer at the bottom of side drawers; max-w-md keeps bottom and top drawers readable on wide screens. */}
Swipe {direction} to close The drawer enters from the edge it is dismissed toward. }> Close
))} ) } ``` ### Snap points `snapPoints` lets a bottom drawer rest at preset heights. Numbers from 0 to 1 are fractions of the viewport height, larger numbers are pixels, and strings accept `px` and `rem`. The drawer opens at the first point. Control the current point with `snapPoint` and `onSnapPointChange`. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@zenoxai/ui/components/drawer" function DrawerSnapPoints() { return ( }> Open asset summary srv-db-01.prod Drag up for the full summary, down to close.
IP address
10.20.4.17
Operating system
Ubuntu 22.04 LTS
Tenant
Acme Corp
Open alerts
3
Last seen
2 minutes ago
) } ``` ### Nested A drawer opened from inside another stacks on top of it; the one behind scales down and dims. Use it for a confirmation or a second step that belongs to the first drawer. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@zenoxai/ui/components/drawer" function DrawerNested() { return ( }> Alert details
Malware detected ws-finance-07, Acme Corp }>Isolate host
Isolate ws-finance-07? The host loses network access except to the Zenox agent. }> Isolate host }> Cancel
}> Close
) } ``` ## Accessibility - The drawer is modal by default: focus moves into it and stays there, Escape and a click on the backdrop close it, and focus returns to the trigger. `modal={false}` lets the user keep working with the page behind it. - Swiping is never the only way out. Keep a `DrawerClose` button in the footer for keyboard and screen reader users. - Render a `DrawerTitle`: it is the drawer's accessible name. The swipe handle is hidden from assistive technology. ## API reference `import { ... } from "@zenoxai/ui/components/drawer"` ### Drawer | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the drawer after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the drawer completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the drawer imperatively when called. | | defaultOpen | `boolean` | `false` | Whether the drawer is initially open. To render a controlled drawer, use the `open` prop instead. | | defaultSnapPoint | `DrawerSnapPoint \| null` | | The initial snap point value when uncontrolled. | | defaultTriggerId | `string \| null` | | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open drawer. | | disablePointerDismissal | `boolean` | `false` | Whether to prevent the drawer from closing on outside presses. For non-modal drawers, this also prevents the drawer from closing when focus moves outside of it. | | handle | `DrawerHandle` | | A handle to associate the drawer with a trigger. If specified, allows detached triggers to control the drawer's open state. Can be created with the Drawer.createHandle() method. | | modal | `boolean \| "trap-focus"` | `true` | Determines if the drawer enters a modal state when open. - `true`: user interaction is limited to just the drawer: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. - `'trap-focus'`: focus is trapped inside the drawer, but document page scroll is not locked and pointer interactions outside of it remain enabled. | | onOpenChange | `((open: boolean, eventDetails: DrawerRootChangeEventDetails) => void)` | | Event handler called when the drawer is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the drawer is opened or closed. | | onSnapPointChange | `((snapPoint: DrawerSnapPoint \| null, eventDetails: DrawerRootSnapPointChangeEventDetails) => void)` | | Callback fired when the snap point changes. | | open | `boolean` | | Whether the drawer is currently open. | | showSwipeHandle | `boolean` | `false` | Shows a grab bar on the drawer's inner edge. Defaults to false. | | snapPoint | `DrawerSnapPoint \| null` | | The currently active snap point. Use with `onSnapPointChange` to control the snap point. | | snapPoints | `DrawerSnapPoint[]` | | Snap points used to position the drawer. Use numbers between 0 and 1 to represent fractions of the viewport height, numbers greater than 1 as pixel values, or strings in `px`/`rem` units (for example, `'148px'` or `'30rem'`). | | snapToSequentialPoints | `boolean` | `false` | Disables velocity-based snap skipping so drag distance determines the next snap point. | | swipeDirection | `"left" \| "right" \| "up" \| "down"` | `down` | The swipe direction used to dismiss the drawer. | | triggerId | `string \| null` | | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `open` prop to create a controlled drawer. There's no need to specify this prop when the drawer is uncontrolled (that is, when the `open` prop is not set). | ### DrawerPortal | Prop | Type | Default | Description | | --- | --- | --- | --- | | container | `HTMLElement \| ShadowRoot \| RefObject \| null` | | A parent element to render the portal element into. | | keepMounted | `boolean` | `false` | Whether to keep the portal mounted in the DOM while the popup is hidden. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### DrawerOverlay | Prop | Type | Default | Description | | --- | --- | --- | --- | | forceRender | `boolean` | `false` | Whether the backdrop is forced to render even when nested. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### DrawerSwipeHandle No props of its own. ### DrawerTrigger | Prop | Type | Default | Description | | --- | --- | --- | --- | | handle | `DrawerHandle` | | A handle to associate the trigger with a drawer. Can be created with the Drawer.createHandle() method. | | id | `string` | | ID of the trigger. In addition to being forwarded to the rendered element, it is also used to specify the active trigger for drawers in controlled mode (with the Drawer.Root `triggerId` prop). | | nativeButton | `boolean` | `true` | Whether the component renders a native ` ) } ``` ## Usage ```tsx import { ServerIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@zenoxai/ui/components/empty" export function NoAssets() { return ( No assets yet Connect a cloud account to build the inventory. ) } ``` Use `Empty` for a whole page or a large region that is empty because the user has not set anything up: no assets, no integrations, no reports. Say what is missing in the title, why it matters or what fills it in the description, and offer the first step in `EmptyContent`. `EmptyMedia variant="icon"` draws the icon on a muted square. The `default` variant renders the media as is, for an illustration. For lists, tables and cards, use the patterns built on these parts. They fix the spacing and the copy rules, and keep an empty result apart from a failed request: - [ListEmptyState](/patterns/list-empty-state) when the request succeeded and returned nothing. - [ListErrorState](/patterns/list-error-state) when the request failed. ## Outlined Add a dashed border when the empty state sits among other content and needs an edge, such as a region of a dashboard. Links in `EmptyDescription` are underlined. ```tsx import { RadarIcon } from "lucide-react" import { Button } from "@zenoxai/ui/components/button" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@zenoxai/ui/components/empty" function EmptyOutlined() { return ( No scans scheduled Schedule a scan to check external domains for exposed services. Read about scan scopes first. ) } ``` ## Accessibility - `EmptyTitle` renders a `
`, not a heading. When the empty state replaces the main content of a page, put a heading inside it, `

No assets yet

`, so the page keeps its outline. - The icon is decorative. The title must say on its own what is empty. ## API reference `import { ... } from "@zenoxai/ui/components/empty"` ### Empty No props of its own. ### EmptyHeader No props of its own. ### EmptyTitle No props of its own. ### EmptyDescription No props of its own. ### EmptyContent No props of its own. ### EmptyMedia | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `"default" \| "icon" \| null` | `default` | `default` shows the media as it is, for an illustration; `icon` sets an icon in a 32px muted tile. | --- # Empty value A muted dash for a missing value in a table, a description list or a detail view, with words for screen readers. ```tsx import { EmptyValue } from "@zenoxai/ui/patterns/empty-value" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@zenoxai/ui/components/table" function EmptyValueDemo() { const assets = [ { name: "srv-db-01", owner: "Platform team", lastScan: "10/23/2026 02:00:14", }, { name: "vm-jump-01", owner: null, lastScan: "10/22/2026 02:00:09" }, { name: "wks-0193", owner: "Marina Santos", lastScan: null }, ] return ( Asset Owner Last scan {assets.map((asset) => ( {asset.name} {asset.owner ?? } {/* The absence means something here, so it says so. */} {asset.lastScan ?? ( Never scanned )} ))}
) } ``` ## Usage ```tsx import { EmptyValue } from "@zenoxai/ui/patterns/empty-value" {asset.owner ?? } ``` Use it wherever a value can be absent: table cells, [Description List](/patterns/description-list) items, detail views. A blank cell is ambiguous (no value, still loading, or broken), and "N/A", "null" or "-" read as data. When the absence itself means something, write that instead, in `text-text-muted`: "Never scanned", "No owner", "Not reported by the agent". The "Last scan" column above does. Pass `label` with the translated text for screen readers; it defaults to "No value". See [Data display](/guidelines/data-display#empty-values) for the rest of the rules about values. ## Accessibility - The dash is hidden from assistive technology; screen readers announce `label` instead. - The dash uses `text-text-muted`, which keeps 4.5:1 on the surface and the muted surface in both themes. ## API reference `import { ... } from "@zenoxai/ui/patterns/empty-value"` ### EmptyValue | Prop | Type | Default | Description | | --- | --- | --- | --- | | label | `string` | `No value` | Already-translated text screen readers announce instead of the dash. | --- # Field Lays out a form control with its label, description and error message, and groups fields into sections. ```tsx import { Field, FieldDescription, FieldLabel } from "@zenoxai/ui/components/field" import { Input } from "@zenoxai/ui/components/input" function FieldDemo() { return ( Tenant name Shown in reports and alert emails. ) } ``` ## Usage ```tsx import { Field, FieldDescription, FieldLabel, } from "@zenoxai/ui/components/field" import { Input } from "@zenoxai/ui/components/input" Tenant name Shown in reports and alert emails. ``` Wrap every form control in a `Field`: [Input](/components/input), [Select](/components/select), [Checkbox](/components/checkbox), [Radio Group](/components/radio-group), [Slider](/components/slider) and the rest. The field sets the spacing between label, control and help text. With `data-disabled` it dims the label; with `data-invalid` it colors the label and help text red. `Field` is layout only. It does not create ids or validate: give the control an `id`, point `FieldLabel` at it with `htmlFor`, and decide in your form code when a value is invalid. | Part | What it is | | ------------------ | ------------------------------------------------------------------ | | `Field` | One control with its label and help text. | | `FieldLabel` | The `
) } ``` ## Accessibility - The root renders `role="progressbar"` with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`. `ProgressLabel` names it through `aria-labelledby`; without one, set `aria-label`. - Screen readers do not announce every change of the value. When the task ends, announce the result separately, with a toast or a `role="status"` message. ## API reference `import { ... } from "@zenoxai/ui/components/progress"` ### Progress | Prop | Type | Default | Description | | --- | --- | --- | --- | | aria-valuetext | `string` | | A string value that provides a user-friendly name for `aria-valuenow`, the current value of the progress bar. | | format | `NumberFormatOptions` | | Options to format the value. | | getAriaValueText | `((formattedValue: string, value: number \| null) => string)` | | Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the progress bar. | | locale | `LocalesArgument` | | The locale used by `Intl.NumberFormat` when formatting the value. Defaults to the user's runtime locale. | | max | `number` | `100` | The maximum value. | | min | `number` | `0` | The minimum value. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | value (required) | `number \| null` | | The current value. The component is indeterminate when value is `null`. | ### ProgressTrack | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### ProgressIndicator | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### ProgressLabel | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### ProgressValue | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | --- # Radio Group Picks exactly one option from a small set, with every option visible at once. ```tsx import { Field, FieldLabel } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" function RadioGroupDemo() { return ( Hourly Daily Weekly ) } ``` ## Usage ```tsx import { Field, FieldLabel } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" Hourly Daily ``` Use a radio group for two to five options that the user should compare before choosing, and when one option is always selected. Set `defaultValue` so the group never starts empty. For more options, or when the choice is secondary, use a [Select](/components/select). For options that can be combined, use [Checkboxes](/components/checkbox). For a choice that changes a view right away, such as a filter, a [Toggle Group](/components/toggle-group) is more compact. Lay out each option as a horizontal [Field](/components/field) with a `FieldLabel` pointing at the item's `id`. The group is a vertical grid with an 8px gap; override it with `className`. ## Examples ### With a description Put the group in a `FieldSet` with a `FieldLegend` that names the choice, and give each option a `FieldDescription` in a `FieldContent`. ```tsx import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" function RadioGroupWithDescription() { return (
When an alert is resolved Notify the owner Only the assigned analyst gets an email. Notify the team Everyone on the tenant's SOC team gets an email. Do not notify The change appears in the alert history only.
) } ``` ### Choice cards Wrap each option's `Field` in a `FieldLabel` to turn it into a bordered card. The whole card is clickable, and the selected card is highlighted. Use `FieldTitle` for the option name inside the card, since the card itself is the label. ```tsx import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" function RadioGroupChoiceCards() { return ( External scan Internet-facing domains and IPs. About 10 minutes. Internal scan Hosts behind the network agent. About 1 hour. ) } ``` ### Disabled `disabled` on a `RadioGroupItem` disables one option; on the `RadioGroup` it disables them all. Set `data-disabled` on the option's `Field` to dim its label. Say in the label why the option is not available. ```tsx import { Field, FieldLabel } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" function RadioGroupDisabled() { return ( 30 days 90 days 1 year (Enterprise plan) ) } ``` ### Controlled Pass `value` and `onValueChange`. `RadioGroup` is not generic, so `onValueChange` receives `unknown`; cast it to the type of your values. ```tsx import * as React from "react" import { Field, FieldLabel } from "@zenoxai/ui/components/field" import { RadioGroup, RadioGroupItem } from "@zenoxai/ui/components/radio-group" function RadioGroupControlled() { const [view, setView] = React.useState("open") return (
setView(value as string)} aria-label="Alerts to show" className="flex w-fit gap-4" > Open Assigned to me All

Value: {view}

) } ``` ## Accessibility - The group has `role="radiogroup"` and needs a name: `aria-label`, or `aria-labelledby` pointing at a visible heading. A `FieldLegend` names the surrounding `FieldSet`, not the group itself. - Tab moves focus into the group to the selected option and out again in one step. Arrow keys move between options and select them. - Each item renders a `` with a hidden `` beside it. The `id` goes to the hidden input, so `FieldLabel htmlFor` names the item and selects it on click. - `name` on `RadioGroup` submits the selected value with a native form. ## API reference `import { ... } from "@zenoxai/ui/components/radio-group"` ### RadioGroup | Prop | Type | Default | Description | | --- | --- | --- | --- | | defaultValue | `any` | | The uncontrolled value of the radio button that should be initially selected. To render a controlled radio group, use the `value` prop instead. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | form | `string` | | Identifies the form that owns the radio inputs. Useful when the radio group is rendered outside the form. | | inputRef | `Ref` | | A ref to access the hidden input element. | | name | `string` | | Identifies the field when a form is submitted. | | onValueChange | `((value: any, eventDetails: { reason: "none"; event: Event; cancel: () => void; allowPropagation: () => void; isCanceled: boolean; isPropagationAllowed: boolean; trigger: Element; }) => void)` | | Callback fired when the value changes. | | readOnly | `boolean` | `false` | Whether the user should be unable to select a different radio button in the group. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | required | `boolean` | `false` | Whether the user must choose a value before submitting a form. | | value | `any` | | The controlled value of the radio item that should be currently selected. To render an uncontrolled radio group, use the `defaultValue` prop instead. | ### RadioGroupItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | disabled | `boolean` | | Whether the component should ignore user interaction. | | inputRef | `Ref` | | A ref to access the hidden input element. | | nativeButton | `boolean` | `true` | Whether the component renders a native ` } /> ) } ``` ## Usage ```tsx import { Button } from "@zenoxai/ui/components/button" import { SectionHeading } from "@zenoxai/ui/patterns/section-heading" export function ApiKeysHeading() { return ( New key} /> ) } ``` Use a section heading to split a long page or a settings form into groups the user can scan. The title is set at the card-title step (18px, semibold); the description is one line in the secondary text color; the action sits on the right and stays at its own width when the title wraps. Use something else when: - The heading is the title of the page. Use [PageHeader](/patterns/page-header). - The group is a self-contained panel with a border. Use a [Card](/components/card) and its `CardTitle`. ## In a settings card A settings card often holds more than one group. Leave out `CardHeader`, open each group with a section heading inside `CardContent`, wrap each group in a `
` and separate the groups with a [Separator](/components/separator). Do not put a section heading under a `CardTitle`: the section heading is the larger of the two and would read as the parent. ```tsx import { Button } from "@zenoxai/ui/components/button" import { Card, CardContent } from "@zenoxai/ui/components/card" import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@zenoxai/ui/components/field" import { Separator } from "@zenoxai/ui/components/separator" import { Switch } from "@zenoxai/ui/components/switch" import { SectionHeading } from "@zenoxai/ui/patterns/section-heading" function SectionHeadingInSettingsCard() { return (
Critical alerts Sent at once, at any hour. Daily digest A summary of the last 24 hours, at 08:00.
Add webhook } />

No webhooks yet.

) } ``` ## Accessibility - `SectionHeading` renders an `

` by default, one step below the page `

`. Set `as` to keep the outline in order when the section sits under another `

`: `as="h3"`, or `as="h4"` one level further down. - The action is outside the heading element, so the heading's accessible name is the title alone. Give the action a label that makes sense on its own ("New key", not "New"). ## API reference `import { ... } from "@zenoxai/ui/patterns/section-heading"` ### SectionHeading | Prop | Type | Default | Description | | --- | --- | --- | --- | | action | `ReactNode` | | Right-aligned slot, usually a small button. | | as | `"h2" \| "h3" \| "h4"` | `h2` | Heading level. Defaults to h2, one step below the page

. | | description | `ReactNode` | | One line under the title. | | title (required) | `ReactNode` | | | --- # Select Picks one option from a short list shown in a popup under a button. ```tsx import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectDemo() { const severities = [ { label: "Critical", value: "critical" }, { label: "High", value: "high" }, { label: "Medium", value: "medium" }, { label: "Low", value: "low" }, ] return ( ) } ``` ## Usage ```tsx import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@zenoxai/ui/components/select" const severities = [ { label: "Critical", value: "critical" }, { label: "High", value: "high" }, ] ``` Pass the options to `Select` as `items`, either an array of `{ label, value }` or an object that maps each value to its label. `SelectValue` uses it to show the label of the selected value; without `items` it shows the raw value (`critical`). Use a select for a list of up to about 15 options that the user knows by name. For a longer list, or one the user searches, use a [Combobox](/components/combobox). For two to five options that should all stay visible, use a [Radio Group](/components/radio-group). On touch-first screens, a [Native Select](/components/native-select) gives the platform's own picker. The trigger is `w-fit` by default. Give it a width (`w-44`) so it does not resize when the value changes, or `w-full` inside a [Field](/components/field). ## Examples ### With a label Put the `id` on `SelectTrigger` and point a `FieldLabel` at it. ```tsx import { Field, FieldDescription, FieldLabel } from "@zenoxai/ui/components/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectWithLabel() { const regions = [ { label: "Europe (Frankfurt)", value: "eu-central" }, { label: "United States (Virginia)", value: "us-east" }, { label: "Asia Pacific (Singapore)", value: "ap-southeast" }, ] return ( Data region Where the tenant's events and reports are stored. ) } ``` ### Groups `SelectGroup` with a `SelectLabel` names a section of options; `SelectSeparator` divides sections. `disabled` on a `SelectItem` keeps it visible but not selectable. ```tsx import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectWithGroups() { const templates = { soc2: "SOC 2 Type II", iso27001: "ISO 27001", pci: "PCI DSS", weekly: "Weekly summary", incident: "Incident report", exposure: "External exposure", } return ( ) } ``` ### Sizes `size="sm"` on the trigger (28px) fits toolbars and table headers, `default` (32px) is the form size and `lg` (36px) matches a large Input or Button. The chevron is 14px at `sm` and 16px otherwise. Below the `md` breakpoint the text is 16px, as in Input, so iOS does not zoom the page when the trigger takes focus. ```tsx import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectSizes() { const statuses = [ { label: "Open", value: "open" }, { label: "In progress", value: "in-progress" }, { label: "Closed", value: "closed" }, ] return ( <> ) } ``` ### Invalid Set `aria-invalid` on `SelectTrigger` and `data-invalid` on the `Field`. ```tsx import { Field, FieldError, FieldLabel } from "@zenoxai/ui/components/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectInvalid() { const analysts = [ { label: "Dana Whitfield", value: "dana" }, { label: "Marcus Oyelaran", value: "marcus" }, { label: "Priya Raman", value: "priya" }, ] return ( Incident owner Assign an owner before escalating the incident. ) } ``` ### Controlled Pass `value` and `onValueChange`. The value is `null` when nothing is selected, so type the state as `string | null`. ```tsx import * as React from "react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select" function SelectControlled() { const ranges = [ { label: "Last 24 hours", value: "24h" }, { label: "Last 7 days", value: "7d" }, { label: "Last 30 days", value: "30d" }, ] const [range, setRange] = React.useState("7d") const selected = ranges.find((item) => item.value === range) return (

Showing alerts from the {selected?.label.toLowerCase()}.

) } ``` ## Accessibility - The trigger has `role="combobox"` and needs a name: a `FieldLabel` pointing at its `id`, or `aria-label`. The selected value alone does not name it. - Arrow keys move through the options, typing jumps to the first option that starts with the typed text, Enter selects and Escape closes without changing the value. - Focus returns to the trigger when the popup closes. - `name` and `required` on `Select` render a hidden input, so the value submits with a native form. ## API reference `import { ... } from "@zenoxai/ui/components/select"` ### Select | Prop | Type | Default | Description | | --- | --- | --- | --- | | actionsRef | `RefObject` | | A ref to imperative actions. - `unmount`: Ends the closing phase of the select after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the select completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the select imperatively when called. | | autoComplete | `string` | | Provides a hint to the browser for autofill. @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete | | defaultOpen | `boolean` | `false` | Whether the select popup is initially open. To render a controlled select popup, use the `open` prop instead. | | defaultValue | `SelectInputValue \| null` | | The uncontrolled value of the select when it's initially rendered. To render a controlled select, use the `value` prop instead. | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | form | `string` | | Identifies the form that owns the hidden input. Useful when the select is rendered outside the form. | | highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | | id | `string` | | The id of the Select. | | inputRef | `Ref` | | A ref to access the hidden input element. | | isItemEqualToValue | `((itemValue: Value, value: Value) => boolean)` | | Custom comparison logic used to determine if a select item value matches the current selected value. Useful when item values are objects without matching referentially. Defaults to `Object.is` comparison. | | items | `readonly Group[] \| Record \| readonly { label: ReactNode; value: any; }[]` | | Data structure of the items rendered in the select popup. When specified, `` renders the label of the selected item instead of the raw value. @example ```tsx const items = { sans: 'Sans-serif', serif: 'Serif', mono: 'Monospace', cursive: 'Cursive', }; ``` | | itemToStringLabel | `((itemValue: Value) => string)` | | When the item values are objects (``), this function converts the object value to a string representation for display in the trigger. If the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop. | | itemToStringValue | `((itemValue: Value) => string)` | | When the item values are objects (``), this function converts the object value to a string representation for form submission. If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. | | modal | `boolean` | `true` | Determines if the select enters a modal state when open. - `true`: user interaction is limited to the select: document page scroll is locked and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. On touch devices, a `true` modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior. | | multiple | `boolean` | `false` | Whether multiple items can be selected. | | name | `string` | | Identifies the field when a form is submitted. | | onOpenChange | `((open: boolean, eventDetails: SelectRootOpenChangeEventDetails) => void)` | | Event handler called when the select popup is opened or closed. | | onOpenChangeComplete | `((open: boolean) => void)` | | Event handler called after any animations complete when the select popup is opened or closed. | | onValueChange | `((value: SelectOutputValue \| (Multiple extends true ? never : null), eventDetails: SelectRootChangeEventDetails) => void)` | | Event handler called when the value of the select changes. | | open | `boolean` | | Whether the select popup is currently open. | | readOnly | `boolean` | `false` | Whether the user should be unable to choose a different option from the select popup. | | required | `boolean` | `false` | Whether the user must choose a value before submitting a form. | | value | `SelectInputValue \| null` | | The value of the select. Use when controlled. | ### SelectContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | align | `"center" \| "start" \| "end"` | `start` | How to align the popup relative to the specified side. | | alignItemWithTrigger | `boolean` | `false` | Whether the positioner overlaps the trigger so the selected item's text is aligned with the trigger's value text. This only applies to mouse input and is automatically disabled if there is not enough space. | | alignOffset | `number \| OffsetFunction` | `0` | Additional offset along the alignment axis in pixels. Also accepts a function that returns the offset to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. @example ```jsx { return side === 'top' \|\| side === 'bottom' ? anchor.width : anchor.height; }} /> ``` | | finalFocus | `boolean \| RefObject \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)` | | Determines the element to focus when the select popup is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing. | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | | side | `"left" \| "right" \| "top" \| "bottom" \| "inline-end" \| "inline-start"` | `bottom` | Which side of the anchor element to align the popup against. May automatically change to avoid collisions. | | sideOffset | `number \| OffsetFunction` | `0` | Distance between the anchor and the popup in pixels. Also accepts a function that returns the distance to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. @example ```jsx { return side === 'top' \|\| side === 'bottom' ? anchor.height : anchor.width; }} /> ``` | ### SelectGroup | Prop | Type | Default | Description | | --- | --- | --- | --- | | render | `ReactElement> \| ComponentRenderFn` | | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | ### SelectItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | disabled | `boolean` | `false` | Whether the component should ignore user interaction. | | label | `string` | | Specifies the text label to use when the item is matched during keyboard text navigation. Defaults to the item text content if not provided. | | nativeButton | `boolean` | `true` | Whether the component renders a native `