# 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 (
)
}
```
## 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.IDAlertAssetSeverityEvents
{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 (
ZenoXVydar 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 happenedA 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)
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 `