# Arbol UI
> @arbol-ai/ui: accessible React Aria components styled with Tailwind CSS v4, in Spanish and English, for building Arbol's dashboards fast.
Index: https://epidermis.getarbol.com/llms.txt
# Arbol UI
> @arbol-ai/ui: accessible React components for building dashboards fast, built on React Aria Components and Tailwind CSS v4, with every built-in string in Spanish and English.
Source: https://epidermis.getarbol.com/ · Markdown: https://epidermis.getarbol.com/index.md
`@arbol-ai/ui` is the component library of [Arbol](https://www.getarbol.com)'s Raíz, Agenda and Account
dashboards: [React Aria Components](https://react-aria.adobe.com) styled with
[Tailwind CSS v4](https://tailwindcss.com), a token-based theme, and an agent skill that teaches coding
agents to assemble pages with it. It is open source under the MIT license and published to npm.
- **Accessible by construction.** Keyboard, screen readers, focus management and touch come from React Aria.
- **Localized.** The library's own strings ship in Spanish and English, follow your app's locale, and can be
replaced with your own translation functions (Paraglide, i18next).
- **Router-agnostic.** Every `href` routes through your router via one provider.
- **Dashboard-ready.** Data tables on TanStack Table, filters, pagination, stats, charts on ECharts, forms on
TanStack Form, overlays with an unsaved-changes guard, an app shell and settings pages.
- **Themeable.** Every color, radius, shadow, font, size and width is a CSS variable: change one and every
component follows. Light, dark, brand and mono.
- **Installation** — The package, its peers and the optional entries.
- **Setup** — The theme, the provider, toasts and your first page.
- **Components** — Every component and block, live, with the code to paste.
- **For agents** — The agent skill, llms.txt, Markdown pages and the MCP server.
## What it holds
Presentation only: components, blocks, the design tokens and the strings components say by themselves. No
API calls, routes, permissions or app copy. Your app passes its own translated text through props and
children, and keeps its state, its data and its URLs.
| Entry | What |
| --- | --- |
| `@arbol-ai/ui` | Every component and block, `UIProvider`, the hooks and types. |
| `@arbol-ai/ui/theme.css` | The tokens, the base layer and Tailwind's `@source` for the library. |
| `@arbol-ai/ui/charts` | Charts on ECharts (optional peer `echarts`). |
| `@arbol-ai/ui/form` | Fields bound to TanStack Form (optional peer `@tanstack/react-form`). |
| `@arbol-ai/ui/router` | `RouterUIProvider` and the unsaved-changes blocker for TanStack Router. |
| `@arbol-ai/ui/query` | `QueryView`: a TanStack Query result as loading, error, empty or data. |
| `@arbol-ai/ui/arbi` | Arbi's chat, shared by every Arbol app (styles: `@arbol-ai/ui/arbi.css`). |
# Components
> Every component and block of @arbol-ai/ui, by category, each with live examples and the code to paste.
Source: https://epidermis.getarbol.com/components/ · Markdown: https://epidermis.getarbol.com/components/index.md
Each page shows a component's live examples, in the library's own theme, and the code for each one,
importing from the package entry that publishes it. The same examples run in the library's tests.
## Actions
- [Button](https://epidermis.getarbol.com/components/actions/button/)
- [CopyButton](https://epidermis.getarbol.com/components/actions/copy-button/)
- [IconButton](https://epidermis.getarbol.com/components/actions/icon-button/)
## Forms
- [Checkbox](https://epidermis.getarbol.com/components/forms/checkbox/)
- [Combo box](https://epidermis.getarbol.com/components/forms/combo-box/)
- [Field](https://epidermis.getarbol.com/components/forms/field/)
- [Form](https://epidermis.getarbol.com/components/forms/form/)
- [Form save bar](https://epidermis.getarbol.com/components/forms/form-save-bar/)
- [Number field](https://epidermis.getarbol.com/components/forms/number-field/)
- [Radio group](https://epidermis.getarbol.com/components/forms/radio-group/)
- [Search field](https://epidermis.getarbol.com/components/forms/search-field/)
- [Segmented control](https://epidermis.getarbol.com/components/forms/segmented-control/)
- [Select](https://epidermis.getarbol.com/components/forms/select/)
- [Slider](https://epidermis.getarbol.com/components/forms/slider/)
- [Switch](https://epidermis.getarbol.com/components/forms/switch/)
- [TanStack Form](https://epidermis.getarbol.com/components/forms/app-form/)
- [Text field](https://epidermis.getarbol.com/components/forms/text-field/)
## Pickers
- [Calendar](https://epidermis.getarbol.com/components/pickers/calendar/)
- [ColorSwatchPicker](https://epidermis.getarbol.com/components/pickers/color-swatch-picker/)
- [DateField](https://epidermis.getarbol.com/components/pickers/date-field/)
- [DatePicker](https://epidermis.getarbol.com/components/pickers/date-picker/)
- [DateRangeFilter](https://epidermis.getarbol.com/components/pickers/date-range-filter/)
- [FileUploadButton](https://epidermis.getarbol.com/components/pickers/file-trigger/)
- [PhoneField](https://epidermis.getarbol.com/components/pickers/phone-field/)
## Overlays
- [CommandPalette](https://epidermis.getarbol.com/components/overlays/command-palette/)
- [ConfirmDialog](https://epidermis.getarbol.com/components/overlays/confirm-dialog/)
- [InfoTip](https://epidermis.getarbol.com/components/overlays/info-tip/)
- [Menu](https://epidermis.getarbol.com/components/overlays/menu/)
- [Overlay](https://epidermis.getarbol.com/components/overlays/overlay/)
- [Popover](https://epidermis.getarbol.com/components/overlays/popover/)
- [RowMenu](https://epidermis.getarbol.com/components/overlays/row-menu/)
- [Tooltip](https://epidermis.getarbol.com/components/overlays/tooltip/)
## Navigation
- [Breadcrumbs](https://epidermis.getarbol.com/components/navigation/breadcrumbs/)
- [Link](https://epidermis.getarbol.com/components/navigation/link/)
- [Pagination](https://epidermis.getarbol.com/components/navigation/pagination/)
- [Tabs](https://epidermis.getarbol.com/components/navigation/tabs/)
## Collections
- [Filter chips](https://epidermis.getarbol.com/components/collections/filter-chips/)
- [Pick list](https://epidermis.getarbol.com/components/collections/pick-list/)
- [Tag group](https://epidermis.getarbol.com/components/collections/tag-group/)
## Data
- [Avatar](https://epidermis.getarbol.com/components/data/avatar/)
- [Badge](https://epidermis.getarbol.com/components/data/badge/)
- [ColumnPicker](https://epidermis.getarbol.com/components/data/column-picker/)
- [CountBadge](https://epidermis.getarbol.com/components/data/count-badge/)
- [DataTable](https://epidermis.getarbol.com/components/data/data-table/)
- [FilterSelect](https://epidermis.getarbol.com/components/data/filter-select/)
- [FoldingFilters](https://epidermis.getarbol.com/components/data/folding-filters/)
- [Kbd](https://epidermis.getarbol.com/components/data/kbd/)
- [Stat](https://epidermis.getarbol.com/components/data/stat/)
- [StatusDot](https://epidermis.getarbol.com/components/data/status-dot/)
- [Table](https://epidermis.getarbol.com/components/data/table/)
- [TanStack Query](https://epidermis.getarbol.com/components/data/query-view/)
## Feedback
- [EmptyState](https://epidermis.getarbol.com/components/feedback/empty-state/)
- [Notice](https://epidermis.getarbol.com/components/feedback/notice/)
- [ProgressBar](https://epidermis.getarbol.com/components/feedback/progress-bar/)
- [Skeleton](https://epidermis.getarbol.com/components/feedback/skeleton/)
- [Spinner](https://epidermis.getarbol.com/components/feedback/spinner/)
- [State screens](https://epidermis.getarbol.com/components/feedback/state-screen/)
- [TableSkeleton](https://epidermis.getarbol.com/components/feedback/table-skeleton/)
- [Toast](https://epidermis.getarbol.com/components/feedback/toast/)
## Layout
- [App shell](https://epidermis.getarbol.com/components/layout/app-shell/)
- [Card](https://epidermis.getarbol.com/components/layout/card/)
- [Disclosure](https://epidermis.getarbol.com/components/layout/disclosure/)
- [Page](https://epidermis.getarbol.com/components/layout/page/)
- [Save bar](https://epidermis.getarbol.com/components/layout/save-bar/)
- [Section header](https://epidermis.getarbol.com/components/layout/section-header/)
- [Separator](https://epidermis.getarbol.com/components/layout/separator/)
- [Settings](https://epidermis.getarbol.com/components/layout/settings/)
- [TanStack Router](https://epidermis.getarbol.com/components/layout/router/)
## Charts
- [BarChart](https://epidermis.getarbol.com/components/charts/bar-chart/)
- [CalendarHeatmap](https://epidermis.getarbol.com/components/charts/calendar-heatmap/)
- [Chart](https://epidermis.getarbol.com/components/charts/chart/)
- [ChartPanel](https://epidermis.getarbol.com/components/charts/chart-panel/)
- [DonutChart](https://epidermis.getarbol.com/components/charts/donut-chart/)
- [FunnelChart](https://epidermis.getarbol.com/components/charts/funnel-chart/)
- [GaugeChart](https://epidermis.getarbol.com/components/charts/gauge-chart/)
- [Sparkline](https://epidermis.getarbol.com/components/charts/sparkline/)
- [TrendChart](https://epidermis.getarbol.com/components/charts/trend-chart/)
## Blocks
- [Account menu](https://epidermis.getarbol.com/components/blocks/account-menu/)
- [BreakdownList](https://epidermis.getarbol.com/components/blocks/breakdown-list/)
- [ChartCard](https://epidermis.getarbol.com/components/blocks/chart-card/)
- [Collapsible card](https://epidermis.getarbol.com/components/blocks/collapsible-card/)
- [DeleteDialog](https://epidermis.getarbol.com/components/blocks/delete-dialog/)
- [Editable title](https://epidermis.getarbol.com/components/blocks/editable-title/)
- [FormDialog and FormSheet](https://epidermis.getarbol.com/components/blocks/form-dialog/)
- [KpiGrid](https://epidermis.getarbol.com/components/blocks/kpi-grid/)
- [ListPage](https://epidermis.getarbol.com/components/blocks/list-page/)
- [ListTable](https://epidermis.getarbol.com/components/blocks/list-table/)
- [ListToolbar](https://epidermis.getarbol.com/components/blocks/list-toolbar/)
- [Logo tile](https://epidermis.getarbol.com/components/blocks/logo-tile/)
- [Organization menu](https://epidermis.getarbol.com/components/blocks/organization-menu/)
- [OverlayActions](https://epidermis.getarbol.com/components/blocks/overlay-actions/)
- [OverlayLayout](https://epidermis.getarbol.com/components/blocks/overlay-layout/)
- [PeriodPicker](https://epidermis.getarbol.com/components/blocks/period-picker/)
- [Product mark](https://epidermis.getarbol.com/components/blocks/product-mark/)
- [Property list](https://epidermis.getarbol.com/components/blocks/property-list/)
- [Record header](https://epidermis.getarbol.com/components/blocks/record-header/)
- [Record layout](https://epidermis.getarbol.com/components/blocks/record-layout/)
- [Record page](https://epidermis.getarbol.com/components/blocks/record-page/)
- [Record pager](https://epidermis.getarbol.com/components/blocks/record-pager/)
- [ReportHeader](https://epidermis.getarbol.com/components/blocks/report/)
- [ReportPage](https://epidermis.getarbol.com/components/blocks/report-page/)
- [Route states](https://epidermis.getarbol.com/components/blocks/route-states/)
- [Section nav](https://epidermis.getarbol.com/components/blocks/section-nav/)
- [SelectionBar](https://epidermis.getarbol.com/components/blocks/selection-bar/)
- [Settings card](https://epidermis.getarbol.com/components/blocks/settings-card/)
- [Settings links](https://epidermis.getarbol.com/components/blocks/settings-links/)
- [Settings page](https://epidermis.getarbol.com/components/blocks/settings-page/)
# Actions
> The actions components of @arbol-ai/ui: Button, CopyButton, IconButton.
Source: https://epidermis.getarbol.com/components/actions/ · Markdown: https://epidermis.getarbol.com/components/actions/index.md
- [Button](https://epidermis.getarbol.com/components/actions/button/): An action on the current page. One filled button per area marks the usual next step; outline, secondary and ghost buttons carry the rest. Use ButtonLink when the action navigates.
- [CopyButton](https://epidermis.getarbol.com/components/actions/copy-button/): Copies a value to the clipboard, then shows a check and "Copied" for two seconds, or "Couldn’t copy" when the browser refuses, and announces the same to screen readers.
- [IconButton](https://epidermis.getarbol.com/components/actions/icon-button/): A button that shows only an icon. Its required label is both its accessible name and the tooltip that names it on hover and keyboard focus.
# Button
> An action on the current page. One filled button per area marks the usual next step; outline, secondary and ghost buttons carry the rest. Use ButtonLink when the action navigates.
Source: https://epidermis.getarbol.com/components/actions/button/ · Markdown: https://epidermis.getarbol.com/components/actions/button/index.md
## Variants
```tsx
import { Button } from '@arbol-ai/ui';
Save changes
Cancel
Duplicate
Skip
Learn more
Delete contact
```
## Sizes and icons
Icon-only buttons need an aria-label; a title adds a visible hint.
```tsx
import { DownloadSimpleIcon, PlusIcon, TrashIcon } from '@phosphor-icons/react';
import { Button } from '@arbol-ai/ui';
New
Export
Book appointment
```
## Pending
While an action runs the button keeps focus, ignores presses and shows a spinner.
```tsx
import { useState } from 'react';
import { Button } from '@arbol-ai/ui';
const PendingExample = () => {
const [isPending, setIsPending] = useState(false);
return (
{
setIsPending(true);
setTimeout(() => {
setIsPending(false);
}, 1500);
}}
>
{isPending ? 'Saving…' : 'Save changes'}
);
};
```
## Link styled as a button
```tsx
import { ButtonLink } from '@arbol-ai/ui';
Open the list
```
## The look on another element
buttonVariants gives a router’s link or another library’s trigger the same look, hover and keyboard focus included.
```tsx
import { ArrowLeftIcon } from '@phosphor-icons/react';
import { buttonVariants } from '@arbol-ai/ui';
Back to the agenda
```
## Source
The examples above are [`src/components/button/button.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/button/button.examples.tsx), which the library's tests render too.
# CopyButton
> Copies a value to the clipboard, then shows a check and "Copied" for two seconds, or "Couldn’t copy" when the browser refuses, and announces the same to screen readers.
Source: https://epidermis.getarbol.com/components/actions/copy-button/ · Markdown: https://epidermis.getarbol.com/components/actions/copy-button/index.md
## Beside a value
label names what is copied, in the app’s language, starting with the visible word ("Copy", "Copiar").
```tsx
import { useLocale } from 'react-aria-components';
import { CopyButton } from '@arbol-ai/ui';
const OrganizationIdExample = () => {
const { locale } = useLocale();
// The app passes its own copy, translated by its message system.
const label = locale.startsWith('es') ? 'Copiar el ID de la organización' : 'Copy the organization ID';
return (
org_01JF8Z3KQ4V7W2N6
);
};
```
## Other words
Per instance, or for the whole app through UIProvider messages.
```tsx
import { CopyButton } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/copy-button/copy-button.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/copy-button/copy-button.examples.tsx), which the library's tests render too.
# IconButton
> A button that shows only an icon. Its required label is both its accessible name and the tooltip that names it on hover and keyboard focus.
Source: https://epidermis.getarbol.com/components/actions/icon-button/ · Markdown: https://epidermis.getarbol.com/components/actions/icon-button/index.md
## In a table row
```tsx
import { ArchiveIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { IconButton } from '@arbol-ai/ui';
} label="Edit the appointment" size="sm" />
} label="Archive the appointment" size="sm" />
} label="Delete the appointment" size="sm" />
```
## Looks and sizes
```tsx
import { DownloadSimpleIcon } from '@phosphor-icons/react';
import { IconButton } from '@arbol-ai/ui';
} label="Export the report" variant="outline" />
}
label="Export the contacts"
size="lg"
variant="secondary"
/>
```
## Source
The examples above are [`src/components/icon-button/icon-button.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/icon-button/icon-button.examples.tsx), which the library's tests render too.
# Blocks
> The blocks components of @arbol-ai/ui: Account menu, BreakdownList, ChartCard, Collapsible card, DeleteDialog, Editable title, FormDialog and FormSheet, KpiGrid, ListPage, ListTable, ListToolbar, Logo tile, Organization menu, OverlayActions, OverlayLayout, PeriodPicker, Product mark, Property list, Record header, Record layout, Record page, Record pager, ReportHeader, ReportPage, Route states, Section nav, SelectionBar, Settings card, Settings links, Settings page.
Source: https://epidermis.getarbol.com/components/blocks/ · Markdown: https://epidermis.getarbol.com/components/blocks/index.md
- [Account menu](https://epidermis.getarbol.com/components/blocks/account-menu/): Who is signed in, at the foot of AppShell’s sidebar: their picture and email, opening a menu with their name, the app’s items, the theme and language submenus, and signing out. On the rail only the picture shows.
- [BreakdownList](https://epidermis.getarbol.com/components/blocks/breakdown-list/): A part-to-whole breakdown as a list of meters: each row’s label, figure and share, with a bar against the largest. Rows lead to what they count, or narrow the page as toggles. It needs no chart library, reads well on a phone and says when there is nothing to show.
- [ChartCard](https://epidermis.getarbol.com/components/blocks/chart-card/): A finished chart card: a title and the data, and it draws the figure with its change, the legend the chart needs, the chart, a placeholder while loading and a note when there is nothing to draw. Trend, bars, donut, breakdown, funnel, gauge and heatmap; for anything else, compose ChartPanel.
- [Collapsible card](https://epidermis.getarbol.com/components/blocks/collapsible-card/): A card whose head folds it: an optional part of a form, or one item of a list edited in place. A control beside the head (Remove) never folds it.
- [DeleteDialog](https://epidermis.getarbol.com/components/blocks/delete-dialog/): The question before deleting a record for good: it names the record, says what deleting means, and Delete waits until the name is typed. Its words come in Spanish or English.
- [Editable title](https://epidermis.getarbol.com/components/blocks/editable-title/): A record’s name renamed in place: press it, type, Enter or leave the field to save, Esc to put it back. It takes the look of the heading it sits in.
- [FormDialog and FormSheet](https://epidermis.getarbol.com/components/blocks/form-dialog/): A TanStack form in a dialog (a create flow) or a side sheet (an editor), finished: give it the form, a title and the fields. Enter and Ctrl+Enter submit, the save shows and blocks dismissal, it closes once onSubmit resolves and stays open with the reason when it throws, and closing with changes asks first.
- [KpiGrid](https://epidermis.getarbol.com/components/blocks/kpi-grid/): The headline figures of a report, from data: each with its change against the previous period, colored by what the change means, its definition behind an ⓘ, a line of context, a live dot and a link to what it counts. A reading of the figures sits beside one or two of them and under more.
- [ListPage](https://epidermis.getarbol.com/components/blocks/list-page/): The page of a resource’s records: the title with the count of what the list holds, a line on what it is for and the action that adds one, over its ListTable. A list page is these two blocks and the app’s data.
- [ListTable](https://epidermis.getarbol.com/components/blocks/list-table/): A resource’s whole list from rows, columns and callbacks: the toolbar (search, filters, saved views, chips, columns), sortable headers, rows that open their record, row menus, every state inside the frame, pages by number or cursor, and a bar for the selected rows. Built from ListToolbar, DataTable, RowTitle, RowMenu, EmptyState, Pagination and SelectionBar, each usable alone.
- [ListToolbar](https://epidermis.getarbol.com/components/blocks/list-toolbar/): The controls above a list in the order every list keeps: the search, the filters with “Clear filters” while any is applied, the saved views and the column picker at the end, and a row of chips under it. On a phone the filters fold behind one button.
- [Logo tile](https://epidermis.getarbol.com/components/blocks/logo-tile/): An organization’s, a clinic’s or an integration’s logo on a white tile, at a product mark’s sizes so the two sit side by side. It tries each source in turn and shows an icon, or the initials, while none loads.
- [Organization menu](https://epidermis.getarbol.com/components/blocks/organization-menu/): The organization at the top of AppShell’s sidebar: its logo and name, opening a menu with its whole name, the app’s items and Switch organization. On the rail only the logo shows; while it loads, a placeholder holds its place.
- [OverlayActions](https://epidermis.getarbol.com/components/blocks/overlay-actions/): The actions at the foot of a form in an overlay: Cancel (or Back, in steps) and the submit button, which shows the save running and submits with Ctrl+Enter from any field. FormDialog and FormSheet render them; use them alone for a form of your own.
- [OverlayLayout](https://epidermis.getarbol.com/components/blocks/overlay-layout/): The standard overlay that is not a form: a title and description with the close button, a body that scrolls, and a footer that stays in view. A record in a sheet, settings that apply at once, or an alert-sized card that is not a question.
- [PeriodPicker](https://epidermis.getarbol.com/components/blocks/period-picker/): The period a report or dashboard reads: presets such as the last 7 days or this month, then a custom range. A report always has a period, so there is nothing to clear; the popover states the exact days and, while comparing, the previous period. The value is plain days the app keeps in the URL.
- [Product mark](https://epidermis.getarbol.com/components/blocks/product-mark/): Each of Arbol’s products as its brand tile, the same in every app. It is decoration: write the product’s name beside it. In the sidebar, productIcon(product) makes it an entry’s icon for the apps that open in a new tab.
- [Property list](https://epidermis.getarbol.com/components/blocks/property-list/): A record’s properties as labels and values: stacked for a record’s data or an aside, inline for dates and amounts. An empty value shows a dash, read as “Not set”.
- [Record header](https://epidermis.getarbol.com/components/blocks/record-header/): The head of a record’s page, from data: its avatar or icon, its name as the page’s title with its status, a line of facts, and its actions: the common ones in outline, the next step filled and the rest in a ⋯ menu. RecordPage draws it; use it alone in a page laid out by hand.
- [Record layout](https://epidermis.getarbol.com/components/blocks/record-layout/): A record’s page body: the main column, and an aside with its properties that stays in view as the page scrolls. Below xl the aside folds into a disclosure above the main column.
- [Record page](https://epidermis.getarbol.com/components/blocks/record-page/): The page of one record, finished: pass the header’s data, the sections as items and the properties as label/value items, and fill in the content. Sections are tabs of the page (each with its content) or links to their own URLs (with the route’s content as children).
- [Record pager](https://epidermis.getarbol.com/components/blocks/record-pager/): Moves from one record to the next without going back to the list: up and down arrows, the record’s place, and K and J on the keyboard. Arrows are links for records with their own page, or callbacks for records shown in a sheet.
- [ReportHeader](https://epidermis.getarbol.com/components/blocks/report/): The parts ReportPage is assembled from, for a page it does not fit. ReportHeader states the question and the exact days it covers, with the period and filters under it and the export at its end; ReportReading says what the figures mean in a sentence.
- [ReportPage](https://epidermis.getarbol.com/components/blocks/report-page/): A finished report or dashboard page from data: the question and its exact days, the period (presets, a custom range and the comparison with the previous period), the report’s filters and its export; then the headline figures, their reading and the chart cards, each with its loading and empty states. The app passes data and callbacks; every part is also public.
- [Route states](https://epidermis.getarbol.com/components/blocks/route-states/): What a page shows instead of itself: RouteErrorState reads the failure’s kind (offline, session ended, no access, not found, the server’s) and offers the ways out it allows; NotFoundState and NoAccessState name two of them; AppLoadingState is the screen while the app starts. RouterErrorState and RouterNotFoundState, from the router entry, plug them into TanStack Router.
- [Section nav](https://epidermis.getarbol.com/components/blocks/section-nav/): Tabs that are pages: a record’s or a settings area’s sections, each at its own URL, as a navigation of links whose thumb slides to the current one. On phones they wrap into balanced rows. Use Tabs for sections inside one page.
- [SelectionBar](https://epidermis.getarbol.com/components/blocks/selection-bar/): The actions for the rows selected in a list: how many are selected, what can be done with all of them at once and a way to clear the selection, floating at the bottom of the screen while the list scrolls.
- [Settings card](https://epidermis.getarbol.com/components/blocks/settings-card/): One topic of a settings or record page as a card: icon, title, description and one action at its head, then its fields, and Save and Cancel at its foot when it edits in place.
- [Settings links](https://epidermis.getarbol.com/components/blocks/settings-links/): A settings landing: groups of cards, each a link to one settings page with its icon, what it is for and how it stands today. On a phone each group is one compact list.
- [Settings page](https://epidermis.getarbol.com/components/blocks/settings-page/): A settings page, finished: pass its sections as items (each with its rows), its draft’s save and the router’s unsaved-changes guard. The sections are one form that the save bar at its foot saves together; settings that apply at once need no save.
# Account menu
> Who is signed in, at the foot of AppShell’s sidebar: their picture and email, opening a menu with their name, the app’s items, the theme and language submenus, and signing out. On the rail only the picture shows.
Source: https://epidermis.getarbol.com/components/blocks/account-menu/ · Markdown: https://epidermis.getarbol.com/components/blocks/account-menu/index.md
## In the sidebar
The app’s items, then the preferences, then signing out.
```tsx
import { UserCircleIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { AccountMenu, ColorSchemeSubmenu, LanguageSubmenu, MenuItem, MenuSeparator } from '@arbol-ai/ui';
import type { ColorScheme } from '@arbol-ai/ui';
const LANGUAGES = [
{ label: 'English', value: 'en' },
{ label: 'Español', value: 'es' },
] as const;
const PreferencesExample = () => {
const [colorScheme, setColorScheme] = useState('system');
const [language, setLanguage] = useState<'en' | 'es'>('en');
return (
);
};
```
## On the rail, with the person’s role
```tsx
import { ArrowSquareOutIcon, UsersThreeIcon } from '@phosphor-icons/react';
import { AccountMenu, MenuItem } from '@arbol-ai/ui';
undefined}
>
}>
Team
}
target="_blank"
>
Manage your account
```
## Source
The examples above are [`src/blocks/account-menu/account-menu.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/account-menu/account-menu.examples.tsx), which the library's tests render too.
# BreakdownList
> A part-to-whole breakdown as a list of meters: each row’s label, figure and share, with a bar against the largest. Rows lead to what they count, or narrow the page as toggles. It needs no chart library, reads well on a phone and says when there is nothing to show.
Source: https://epidermis.getarbol.com/components/blocks/breakdown-list/ · Markdown: https://epidermis.getarbol.com/components/blocks/breakdown-list/index.md
## Rows that lead to their list
The colored dot ties each row to the same color elsewhere on the page.
```tsx
import { BreakdownList } from '@arbol-ai/ui';
import { ChartPanel } from '@arbol-ai/ui/charts';
```
## Rows that narrow the page
Pressing a row shows only its appointments below; pressing it again shows them all.
```tsx
import { useState } from 'react';
import { BreakdownList } from '@arbol-ai/ui';
import { ChartPanel } from '@arbol-ai/ui/charts';
const REASONS = [
{ id: 'forgot', label: 'Forgot', value: 18, caption: 'no-show' },
{ id: 'transport', label: 'No transport', value: 11, caption: 'no-show' },
{ id: 'rescheduled', label: 'Rescheduled elsewhere', value: 7, caption: 'cancelled' },
{ id: 'none', label: 'No reason given', value: 5, caption: 'cancelled', color: 'bg-gray-a6' },
];
const DrillDownExample = () => {
const [reason, setReason] = useState(null);
return (
);
};
```
## Rates and loading
Rates fill a whole bar at 100%; a row without a rate shows a dash.
```tsx
import { BreakdownList } from '@arbol-ai/ui';
import { ChartPanel } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/blocks/breakdown-list/breakdown-list.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/breakdown-list/breakdown-list.examples.tsx), which the library's tests render too.
# ChartCard
> A finished chart card: a title and the data, and it draws the figure with its change, the legend the chart needs, the chart, a placeholder while loading and a note when there is nothing to draw. Trend, bars, donut, breakdown, funnel, gauge and heatmap; for anything else, compose ChartPanel.
Source: https://epidermis.getarbol.com/components/blocks/chart-card/ · Markdown: https://epidermis.getarbol.com/components/blocks/chart-card/index.md
## A trend against the previous period
The legend names the two periods by itself.
```tsx
import { ChartCard } from '@arbol-ai/ui/charts';
const WEEK = [312, 298, 341, 356, 330, 142, 118].map((value, index) => ({
date: new Date(2026, 10, 1 + index),
value,
previous: [290, 305, 322, 318, 301, 130, 121][index] ?? null,
}));
```
## Bars, a donut and a breakdown
```tsx
import { useState } from 'react';
import { ChartCard } from '@arbol-ai/ui/charts';
const BreakdownExample = () => {
const [reason, setReason] = useState(null);
return (
);
};
```
## A rate
```tsx
import { ChartCard } from '@arbol-ai/ui/charts';
```
## Loading and nothing to draw
```tsx
import { ChartCard } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/charts/chart-card.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/chart-card.examples.tsx), which the library's tests render too.
# Collapsible card
> A card whose head folds it: an optional part of a form, or one item of a list edited in place. A control beside the head (Remove) never folds it.
Source: https://epidermis.getarbol.com/components/blocks/collapsible-card/ · Markdown: https://epidermis.getarbol.com/components/blocks/collapsible-card/index.md
## Advanced settings
```tsx
import { SlidersHorizontalIcon } from '@phosphor-icons/react';
import { CollapsibleCard, TextField } from '@arbol-ai/ui';
}
title="Advanced settings"
>
```
## One item open at a time
The parent controls which card is open; each card’s summary says what it holds.
```tsx
import { MapPinIcon, PhoneIcon, TrashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { CollapsibleCard, IconButton, TextField } from '@arbol-ai/ui';
const ADDRESSES = [
{ id: 'home', title: 'Home', summary: 'Calle 72 #10-34, Bogotá', icon: },
{ id: 'mobile', title: 'Second phone', summary: '+57 310 555 0199', icon: },
];
const ContactPoints = () => {
const [open, setOpen] = useState('home');
return (
{ADDRESSES.map((point) => (
} label={`Remove ${point.title}`} size="sm" />}
description={point.summary}
icon={point.icon}
isExpanded={open === point.id}
key={point.id}
onExpandedChange={(isExpanded) => {
setOpen(isExpanded ? point.id : null);
}}
title={point.title}
>
))}
);
};
```
## Source
The examples above are [`src/blocks/collapsible-card/collapsible-card.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/collapsible-card/collapsible-card.examples.tsx), which the library's tests render too.
# DeleteDialog
> The question before deleting a record for good: it names the record, says what deleting means, and Delete waits until the name is typed. Its words come in Spanish or English.
Source: https://epidermis.getarbol.com/components/blocks/delete-dialog/ · Markdown: https://epidermis.getarbol.com/components/blocks/delete-dialog/index.md
## Delete a contact
Type the name to enable Delete; the delete shows while it runs, then the question closes.
```tsx
import { useState } from 'react';
import { Button, DeleteDialog } from '@arbol-ai/ui';
const DeleteContactExample = () => {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
Delete contact
{
setIsPending(true);
setTimeout(() => {
setIsPending(false);
close();
}, 900);
}}
onOpenChange={setIsOpen}
/>
>
);
};
```
## Source
The examples above are [`src/blocks/delete-dialog/delete-dialog.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/delete-dialog/delete-dialog.examples.tsx), which the library's tests render too.
# Editable title
> A record’s name renamed in place: press it, type, Enter or leave the field to save, Esc to put it back. It takes the look of the heading it sits in.
Source: https://epidermis.getarbol.com/components/blocks/editable-title/ · Markdown: https://epidermis.getarbol.com/components/blocks/editable-title/index.md
## Page title
```tsx
import { useState } from 'react';
import { EditableTitle, PageTitle } from '@arbol-ai/ui';
const AgentName = () => {
const [name, setName] = useState('');
return (
);
};
```
## Card title, and read-only
Inside a smaller heading it takes that heading’s size; isReadOnly shows the name as text.
```tsx
import { Card, CardContent, CardHeader, CardTitle, EditableTitle, PageTitle } from '@arbol-ai/ui';
undefined} value="March recall list" />
1,240 contacts
undefined} value="Front desk agent" />
```
## Source
The examples above are [`src/blocks/record-header/editable-title.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/record-header/editable-title.examples.tsx), which the library's tests render too.
# FormDialog and FormSheet
> A TanStack form in a dialog (a create flow) or a side sheet (an editor), finished: give it the form, a title and the fields. Enter and Ctrl+Enter submit, the save shows and blocks dismissal, it closes once onSubmit resolves and stays open with the reason when it throws, and closing with changes asks first.
Source: https://epidermis.getarbol.com/components/blocks/form-dialog/ · Markdown: https://epidermis.getarbol.com/components/blocks/form-dialog/index.md
## Create flow in a dialog
The shortest real call: a form, a title, the fields. Submit it empty to see the field errors, with a name ending in “Test” to see a refusal, or with a name to save and close.
```tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Key } from 'react-aria-components';
import { Button, SelectItem } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';
const roundTrip = async () => {
// oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
await new Promise((resolve) => {
setTimeout(resolve, 900);
});
};
const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
{label}
{isOpen
? children(() => {
setIsOpen(false);
})
: null}
>
);
};
const NewContactExample = () => (
{(close) => }
);
const NewContactDialog = ({ onClose }: { onClose: () => void }) => {
const form = useAppForm({
defaultValues: { name: '', phone: '', channel: null as Key | null },
onSubmit: async () => {
await roundTrip();
},
validators: {
onSubmit: ({ value }) =>
value.name.endsWith('Test') ? 'Test contacts are turned off for this clinic.' : undefined,
},
});
return (
(value.trim() === '' ? 'Enter a name.' : undefined) }}
>
{(field) => }
/^\+?[\d\s-]{7,}$/u.test(value) ? undefined : 'Enter a phone like +57 300 555 0101.',
}}
>
{(field) => }
{(field) => (
WhatsApp
Phone call
)}
);
};
```
## Editor in a sheet
Change the note, then press Esc to see the discard question. Delete, at the start of the footer, asks for the name first.
```tsx
import { TrashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button, DeleteDialog, OverlayTrigger } from '@arbol-ai/ui';
import { FormSheet, useAppForm } from '@arbol-ai/ui/form';
const roundTrip = async () => {
// oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
await new Promise((resolve) => {
setTimeout(resolve, 900);
});
};
const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
{label}
{isOpen
? children(() => {
setIsOpen(false);
})
: null}
>
);
};
const ContactEditorExample = () => (
{(close) => }
);
const ContactEditor = ({ onClose }: { onClose: () => void }) => {
const form = useAppForm({
defaultValues: { note: 'Prefers mornings.', receivesCampaigns: true },
onSubmit: async () => {
await roundTrip();
},
});
return (
{
close();
onClose();
}}
/>
}
form={form}
onClose={onClose}
size="md"
submitLabel="Save changes"
title="Ana Torres"
>
{(field) => }
{(field) => Receives campaigns }
);
};
```
## Two steps
closeOnSubmit={false} on the first step and onBack on the second: Continue moves on, Back returns, and the last step closes once saved.
```tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@arbol-ai/ui';
import { FormDialog, useAppForm } from '@arbol-ai/ui/form';
const roundTrip = async () => {
// oxlint-disable-next-line promise/avoid-new -- a stand-in for the server the examples do not have.
await new Promise((resolve) => {
setTimeout(resolve, 900);
});
};
const Opener = ({ label, children }: { label: string; children: (close: () => void) => ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
{label}
{isOpen
? children(() => {
setIsOpen(false);
})
: null}
>
);
};
const MergeExample = () => (
{(close) => }
);
const MergeDialog = ({ onClose }: { onClose: () => void }) => {
const [step, setStep] = useState<'pick' | 'confirm'>('pick');
const form = useAppForm({
defaultValues: { duplicate: '' },
onSubmit: async () => {
if (step === 'pick') {
setStep('confirm');
return;
}
await roundTrip();
},
});
return (
{
setStep('pick');
}
: undefined
}
onClose={onClose}
submitLabel={step === 'pick' ? 'Continue' : 'Merge contacts'}
title="Merge contacts"
>
{step === 'pick' ? (
(value.trim() === '' ? 'Enter who to merge.' : undefined) }}
>
{(field) => }
) : (
The duplicate is removed once everything moved.
)}
);
};
```
## Source
The examples above are [`src/form/form-dialog.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/form/form-dialog.examples.tsx), which the library's tests render too.
# KpiGrid
> The headline figures of a report, from data: each with its change against the previous period, colored by what the change means, its definition behind an ⓘ, a line of context, a live dot and a link to what it counts. A reading of the figures sits beside one or two of them and under more.
Source: https://epidermis.getarbol.com/components/blocks/kpi-grid/ · Markdown: https://epidermis.getarbol.com/components/blocks/kpi-grid/index.md
## An overview
```tsx
import { KpiGrid } from '@arbol-ai/ui';
import type { Kpi } from '@arbol-ai/ui';
const OVERVIEW: Kpi[] = [
{ id: 'live', label: 'Live now', value: 18, isLive: true, hint: '12 by phone · 6 on WhatsApp' },
{ id: 'today', label: 'Today', value: 214, change: 0.08, changeLabel: 'vs yesterday' },
{
id: 'rate',
label: 'Success rate',
value: 0.81,
formatOptions: { style: 'percent' },
change: 3.2,
changeUnit: 'points',
info: 'Conversations that reached their goal, out of every conversation with a result.',
},
{
id: 'review',
label: 'To review',
value: 23,
change: -0.12,
changeTone: 'inverse',
info: 'Conversations the agents handed to your team that nobody has closed yet.',
link: { href: '#/kpi-grid', label: 'Review them' },
},
];
```
## One figure and its reading
A report about one figure reads it in a sentence beside it.
```tsx
import { KpiGrid } from '@arbol-ai/ui';
```
## Loading, and a period still ahead
Placeholders hold each figure’s place; a period ahead has nothing to compare with.
```tsx
import { KpiGrid } from '@arbol-ai/ui';
import type { Kpi } from '@arbol-ai/ui';
const OVERVIEW: Kpi[] = [
{ id: 'live', label: 'Live now', value: 18, isLive: true, hint: '12 by phone · 6 on WhatsApp' },
{ id: 'today', label: 'Today', value: 214, change: 0.08, changeLabel: 'vs yesterday' },
{
id: 'rate',
label: 'Success rate',
value: 0.81,
formatOptions: { style: 'percent' },
change: 3.2,
changeUnit: 'points',
info: 'Conversations that reached their goal, out of every conversation with a result.',
},
{
id: 'review',
label: 'To review',
value: 23,
change: -0.12,
changeTone: 'inverse',
info: 'Conversations the agents handed to your team that nobody has closed yet.',
link: { href: '#/kpi-grid', label: 'Review them' },
},
];
```
## Source
The examples above are [`src/blocks/kpi-grid/kpi-grid.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/kpi-grid/kpi-grid.examples.tsx), which the library's tests render too.
# ListPage
> The page of a resource’s records: the title with the count of what the list holds, a line on what it is for and the action that adds one, over its ListTable. A list page is these two blocks and the app’s data.
Source: https://epidermis.getarbol.com/components/blocks/list-page/ · Markdown: https://epidermis.getarbol.com/components/blocks/list-page/index.md
## Contacts
Search, status chips, filters by channel and by agent (records with a second line), the column picker, rows that open their record with the name as a real link, row menus and pages. Narrow the page: the channel and agent columns leave first, and the filters fold behind one button.
```tsx
import { ChatCircleDotsIcon, PhoneIcon, PlusIcon, UsersIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Badge, Button, EmptyState, FilterChips, FilterSelect, ListPage, ListTable, RowTitle } from '@arbol-ai/ui';
import type { ColumnState, DataTableColumn } from '@arbol-ai/ui';
type Channel = 'whatsapp' | 'voice';
type Status = 'active' | 'archived';
interface Contact {
id: string;
name: string;
phone: string;
channel: Channel;
agent: string;
status: Status;
conversations: number;
}
const NAMES = [
'Ana Torres',
'Luis Gómez',
'Marta Ruiz',
'Carlos Peña',
'Sofía Rojas',
'Juan Castro',
'Laura Mejía',
'Pedro Díaz',
'Valentina Ortiz',
'Andrés Vargas',
'Camila Herrera',
'Diego Morales',
'Isabel Navarro',
'Jorge Salazar',
'Lucía Romero',
'Miguel Cárdenas',
'Paula Ríos',
'Ricardo Silva',
];
const AGENTS = [
{ value: 'Front desk', label: 'Front desk', description: 'Answers every call' },
{ value: 'Reminders', label: 'Reminders', description: 'Confirms appointments' },
];
const CONTACTS: Contact[] = NAMES.map((name, index) => ({
id: `c${index + 1}`,
name,
phone: `+57 300 555 ${1000 + index * 37}`,
channel: index % 3 === 0 ? 'voice' : 'whatsapp',
agent: AGENTS[index % 2]?.value ?? 'Front desk',
status: index % 5 === 4 ? 'archived' : 'active',
conversations: (index * 7) % 23,
}));
const CHANNEL_OPTIONS = [
{ value: 'whatsapp', label: 'WhatsApp', icon: },
{ value: 'voice', label: 'Phone', icon: },
];
const COLUMNS: DataTableColumn[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
}
>
{row.original.name}
),
meta: { fill: true },
},
{
accessorKey: 'channel',
enableSorting: false,
header: 'Channel',
size: 130,
cell: ({ row }) => (row.original.channel === 'voice' ? 'Phone' : 'WhatsApp'),
meta: { minTableWidth: 640 },
},
{ accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 150, meta: { minTableWidth: 820 } },
{
accessorKey: 'status',
header: 'Status',
size: 110,
cell: ({ row }) =>
row.original.status === 'active' ? Active : Archived ,
},
{
accessorKey: 'conversations',
header: 'Conversations',
size: 140,
meta: { align: 'end', cellClassName: 'tabular-nums', defaultVisible: false },
},
];
const NO_VALUES: string[] = [];
const ContactsExample = () => {
const [status, setStatus] = useState(null);
const [channels, setChannels] = useState(NO_VALUES);
const [agents, setAgents] = useState(NO_VALUES);
const [pageNumber, setPageNumber] = useState(1);
const [columns, setColumns] = useState>({});
const rows = CONTACTS.filter(
(contact) =>
(status === null || contact.status === status) &&
(channels.length === 0 || channels.includes(contact.channel)) &&
(agents.length === 0 || agents.includes(contact.agent))
);
/** A filter starts again from the first page. */
const filter = (change: () => void) => {
change();
setPageNumber(1);
};
return (
New contact
}
count={rows.length}
description="Everyone your agents talked to."
title="Contacts"
>
values.length > 0).length}
aria-label="Contacts"
chips={
{
filter(() => {
setStatus(next);
});
}}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]}
value={status}
/>
}
columnState={columns}
columns={COLUMNS}
data={rows}
emptyState={
Add contact}
description="Import a CSV or add your first contact by hand."
icon={ }
title="No contacts yet"
/>
}
filters={
<>
{
filter(() => {
setChannels(next);
});
}}
options={CHANNEL_OPTIONS}
selectionMode="multiple"
value={channels}
/>
{
filter(() => {
setAgents(next);
});
}}
options={AGENTS}
value={agents}
/>
>
}
getRowHref={(contact) => `#/contacts/${contact.id}`}
getRowId={(contact) => contact.id}
getSearchText={(contact) => `${contact.name} ${contact.phone}`}
isFiltered={status !== null}
onClearFilters={() => {
filter(() => {
setStatus(null);
setChannels(NO_VALUES);
setAgents(NO_VALUES);
});
}}
onColumnStateChange={setColumns}
pagination={{ onPage: setPageNumber, page: pageNumber }}
rowActions={(contact) => [
{ label: 'Call', href: `tel:${contact.phone.replaceAll(' ', '')}` },
{ label: 'Archive', isDestructive: true, onAction: () => undefined },
]}
rowActionsLabel={(contact) => `Actions for ${contact.name}`}
searchPlaceholder="Search by name or phone"
/>
);
};
```
## While the first page loads
The title and the toolbar stay put while placeholder rows hold the table’s place; the count shows once it is known.
```tsx
import { PlusIcon } from '@phosphor-icons/react';
import { Button, ListPage, ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
interface Campaign {
name: string;
status: string;
recipients: number;
}
const CAMPAIGN_COLUMNS: DataTableColumn[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'status', header: 'Status', size: 120 },
{ accessorKey: 'recipients', header: 'Recipients', size: 120, meta: { align: 'end' } },
];
const NO_CAMPAIGNS: Campaign[] = [];
New campaign
}
title="Campaigns"
>
undefined}
searchPlaceholder="Search campaigns"
/>
```
## Source
The examples above are [`src/blocks/list-page/list-page.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/list-page/list-page.examples.tsx), which the library's tests render too.
# ListTable
> A resource’s whole list from rows, columns and callbacks: the toolbar (search, filters, saved views, chips, columns), sortable headers, rows that open their record, row menus, every state inside the frame, pages by number or cursor, and a bar for the selected rows. Built from ListToolbar, DataTable, RowTitle, RowMenu, EmptyState, Pagination and SelectionBar, each usable alone.
Source: https://epidermis.getarbol.com/components/blocks/list-table/ · Markdown: https://epidermis.getarbol.com/components/blocks/list-table/index.md
## Rows and columns
The shortest list: headers sort, a press anywhere on a row opens it.
```tsx
import { ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
interface Service {
id: string;
name: string;
minutes: number;
price: number;
}
const SERVICES: Service[] = [
{ id: 's1', name: 'General check-up', minutes: 30, price: 80_000 },
{ id: 's2', name: 'Dental cleaning', minutes: 45, price: 120_000 },
{ id: 's3', name: 'Vaccination', minutes: 15, price: 45_000 },
];
const SERVICE_COLUMNS: DataTableColumn[] = [
{ accessorKey: 'name', header: 'Service', meta: { fill: true } },
{ accessorKey: 'minutes', header: 'Minutes', size: 110, meta: { align: 'end' } },
{ accessorKey: 'price', header: 'Price', size: 120, meta: { align: 'end', cellClassName: 'tabular-nums' } },
];
`#/services/${service.id}`}
/>
```
## A list the browser holds
A team or a catalog read whole: the search, the status chips and the pages run in the browser, and the rows have menus, selection and a bulk action.
```tsx
import { DownloadSimpleIcon, UsersIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Badge, Button, EmptyState, FilterChips, ListTable, RowTitle } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
type PatientStatus = 'active' | 'archived';
interface Patient {
id: string;
name: string;
document: string;
status: PatientStatus;
visits: number;
}
const PATIENT_NAMES = [
'Ana Torres',
'Luis Gómez',
'Marta Ruiz',
'Carlos Peña',
'Sofía Rojas',
'Juan Castro',
'Laura Mejía',
'Pedro Díaz',
'Valentina Ortiz',
'Andrés Vargas',
'Camila Herrera',
'Diego Morales',
'Isabel Navarro',
'Jorge Salazar',
'Lucía Romero',
'Miguel Cárdenas',
'Paula Ríos',
];
const PATIENTS: Patient[] = PATIENT_NAMES.map((name, index) => ({
id: `p${index + 1}`,
name,
document: `CC ${(1_020_300 + index * 7919).toLocaleString('es-CO')}`,
status: index % 6 === 5 ? 'archived' : 'active',
visits: (index * 5) % 13,
}));
const PATIENT_COLUMNS: DataTableColumn[] = [
{
accessorKey: 'name',
header: 'Patient',
cell: ({ row }) => (
}
>
{row.original.name}
),
meta: { fill: true },
},
{
accessorKey: 'status',
header: 'Status',
size: 120,
cell: ({ row }) =>
row.original.status === 'active' ? Active : Archived ,
meta: { minTableWidth: 560 },
},
{
accessorKey: 'visits',
header: 'Visits',
size: 100,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
const PatientsExample = () => {
const [status, setStatus] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
return (
(
Export
)}
chips={
{
setStatus(next);
setPageNumber(1);
}}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]}
value={status}
/>
}
columns={PATIENT_COLUMNS}
data={status === null ? PATIENTS : PATIENTS.filter((patient) => patient.status === status)}
emptyState={
New patient}
description="Everyone the clinic books or registers appears here."
icon={ }
title="No patients yet"
/>
}
getRowHref={(patient) => `#/patients/${patient.id}`}
getRowId={(patient) => patient.id}
getSearchText={(patient) => `${patient.name} ${patient.document}`}
isFiltered={status !== null}
onClearFilters={() => {
setStatus(null);
}}
pagination={{ onPage: setPageNumber, page: pageNumber }}
rowActions={(patient) => [
{ label: 'Book an appointment', href: `#/book?patient=${patient.id}` },
{ label: 'Archive', isDestructive: true, onAction: () => undefined },
]}
rowActionsLabel={(patient) => `Actions for ${patient.name}`}
searchPlaceholder="Name or document"
selectionMode="multiple"
/>
);
};
```
## A list the server pages
The search, the agent filter, the sort and the cursor go to the server; while the next page loads, the rows dim and the pressed button waits. The person’s columns are kept by the app.
```tsx
import type { SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import { FilterSelect, ListTable } from '@arbol-ai/ui';
import type { ColumnState, DataTableColumn } from '@arbol-ai/ui';
interface Conversation {
id: string;
contact: string;
agent: string;
started: string;
}
const CONVERSATIONS: Conversation[] = [
{ id: 'v1', contact: 'Ana Torres', agent: 'Front desk', started: '2026-11-07 09:12' },
{ id: 'v2', contact: 'Luis Gómez', agent: 'Reminders', started: '2026-11-07 08:47' },
{ id: 'v3', contact: 'Marta Ruiz', agent: 'Front desk', started: '2026-11-06 17:30' },
{ id: 'v4', contact: 'Carlos Peña', agent: 'Reminders', started: '2026-11-06 16:02' },
];
const CONVERSATION_COLUMNS: DataTableColumn[] = [
{ accessorKey: 'contact', header: 'Contact', meta: { fill: true } },
{ accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 140 },
{
accessorKey: 'started',
header: 'Started',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
const AGENTS = [
{ value: 'Front desk', label: 'Front desk', description: 'Answers every call' },
{ value: 'Reminders', label: 'Reminders', description: 'Confirms appointments' },
];
const NO_VALUES: string[] = [];
const ConversationsExample = () => {
const [q, setQ] = useState('');
const [agents, setAgents] = useState(NO_VALUES);
const [sorting, setSorting] = useState([{ id: 'started', desc: true }]);
const [cursor, setCursor] = useState(0);
const [columns, setColumns] = useState>({});
const [isUpdating, setIsUpdating] = useState(false);
/** What the server would do: answer a moment later. */
const ask = (change: () => void) => {
change();
setIsUpdating(true);
setTimeout(() => {
setIsUpdating(false);
}, 900);
};
const needle = q.trim().toLowerCase();
const rows = CONVERSATIONS.filter(
(conversation) =>
(needle === '' || conversation.contact.toLowerCase().includes(needle)) &&
(agents.length === 0 || agents.includes(conversation.agent))
).toSorted((a, b) => {
const order =
sorting.at(0)?.id === 'contact'
? a.contact.localeCompare(b.contact)
: a.started.localeCompare(b.started);
return sorting.at(0)?.desc === true ? -order : order;
});
return (
0 ? 1 : 0}
aria-label="Conversations"
columnState={columns}
columns={CONVERSATION_COLUMNS}
data={rows.slice(cursor * 2, cursor * 2 + 2)}
filters={
{
ask(() => {
setAgents(next);
setCursor(0);
});
}}
options={AGENTS}
value={agents}
/>
}
getRowId={(conversation) => conversation.id}
isUpdating={isUpdating}
manualSorting
onClearFilters={() => {
ask(() => {
setAgents(NO_VALUES);
setCursor(0);
});
}}
onColumnStateChange={setColumns}
onRowAction={() => undefined}
onSearchChange={(next) => {
ask(() => {
setQ(next);
setCursor(0);
});
}}
onSortingChange={(next) => {
ask(() => {
setSorting(next);
setCursor(0);
});
}}
pagination={{
hasNextPage: rows.length > (cursor + 1) * 2,
hasPreviousPage: cursor > 0,
onNext: () => {
ask(() => {
setCursor((current) => current + 1);
});
},
onPrevious: () => {
ask(() => {
setCursor((current) => current - 1);
});
},
}}
searchDebounceMs={300}
searchPlaceholder="Search by contact"
searchValue={q}
sorting={sorting}
/>
);
};
```
## Source
The examples above are [`src/blocks/list-table/list-table.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/list-table/list-table.examples.tsx), which the library's tests render too.
# ListToolbar
> The controls above a list in the order every list keeps: the search, the filters with “Clear filters” while any is applied, the saved views and the column picker at the end, and a row of chips under it. On a phone the filters fold behind one button.
Source: https://epidermis.getarbol.com/components/blocks/list-toolbar/ · Markdown: https://epidermis.getarbol.com/components/blocks/list-toolbar/index.md
## Search, filters and columns
```tsx
import { useState } from 'react';
import { ColumnPicker, FilterSelect, ListToolbar, SearchField, useColumnState } from '@arbol-ai/ui';
const NO_VALUES: string[] = [];
const COLUMNS = [
{ id: 'title', label: 'Conversation', canHide: false },
{ id: 'contact', label: 'Contact' },
{ id: 'agent', label: 'Agent' },
{ id: 'started', label: 'Started' },
];
const ConversationsToolbar = () => {
const [q, setQ] = useState('');
const [channels, setChannels] = useState(NO_VALUES);
const [results, setResults] = useState(['missed']);
const { onOrderChange, onReset, onVisibleChange, state } = useColumnState({ columns: COLUMNS });
return (
values.length > 0).length}
columns={
}
filters={
<>
>
}
onClearFilters={() => {
setChannels(NO_VALUES);
setResults(NO_VALUES);
}}
search={ }
/>
);
};
```
## Chips, a records filter and saved views
The agent filter lists records with a face and a second line; the views menu is the app’s own, in the views slot.
```tsx
import { BookmarkSimpleIcon, CaretDownIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Button, FilterChips, FilterSelect, FilterToggle, ListToolbar, Menu, MenuItem, MenuTrigger, SearchField } from '@arbol-ai/ui';
const NO_VALUES: string[] = [];
const AGENTS = [
{ value: 'agt_1', label: 'Front desk', description: 'Answers every call' },
{ value: 'agt_2', label: 'Reminders', description: 'Confirms appointments' },
{ value: 'agt_3', label: 'Collections', description: 'Calls about payments' },
].map((agent) => ({ ...agent, icon: }));
const PatientsToolbar = () => {
const [q, setQ] = useState('');
const [kind, setKind] = useState<'first' | 'returning' | null>(null);
const [agents, setAgents] = useState(NO_VALUES);
const [archived, setArchived] = useState(false);
return (
0, archived].filter(Boolean).length}
chips={
}
filters={
<>
>
}
onClearFilters={() => {
setAgents(NO_VALUES);
setArchived(false);
}}
search={ }
views={
Views
Booked by an agent today
No show this week
}
/>
);
};
```
## Source
The examples above are [`src/blocks/list-toolbar/list-toolbar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/list-toolbar/list-toolbar.examples.tsx), which the library's tests render too.
# Logo tile
> An organization’s, a clinic’s or an integration’s logo on a white tile, at a product mark’s sizes so the two sit side by side. It tries each source in turn and shows an icon, or the initials, while none loads.
Source: https://epidermis.getarbol.com/components/blocks/logo-tile/ · Markdown: https://epidermis.getarbol.com/components/blocks/logo-tile/index.md
## Integrations beside a product
```tsx
import { PlugsConnectedIcon } from '@phosphor-icons/react';
import { LogoTile, ProductMark } from '@arbol-ai/ui';
const LOGO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='11' fill='%232563eb'/%3E%3Cpath d='M10 17l4 4 8-9' stroke='white' stroke-width='3' fill='none'/%3E%3C/svg%3E";
Agenda
Salud Total
} name="Custom server" />
Custom server (no logo yet)
Nueva EPS (its initials)
```
## The current logo in a settings row
With alt, the tile is an image that screen readers name.
```tsx
import { BuildingsIcon } from '@phosphor-icons/react';
import { LogoTile } from '@arbol-ai/ui';
const LOGO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='11' fill='%232563eb'/%3E%3Cpath d='M10 17l4 4 8-9' stroke='white' stroke-width='3' fill='none'/%3E%3C/svg%3E";
} name="Clínica Norte" size="xl" src={LOGO} />
PNG, SVG or JPG, ideally square.
```
## Source
The examples above are [`src/blocks/logo-tile/logo-tile.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/logo-tile/logo-tile.examples.tsx), which the library's tests render too.
# Organization menu
> The organization at the top of AppShell’s sidebar: its logo and name, opening a menu with its whole name, the app’s items and Switch organization. On the rail only the logo shows; while it loads, a placeholder holds its place.
Source: https://epidermis.getarbol.com/components/blocks/organization-menu/ · Markdown: https://epidermis.getarbol.com/components/blocks/organization-menu/index.md
## In the sidebar
```tsx
import { GearSixIcon } from '@phosphor-icons/react';
import { MenuItem, OrganizationMenu } from '@arbol-ai/ui';
const LOGO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230d8273'/%3E%3Cpath d='M16 7v18M7 16h18' stroke='white' stroke-width='5'/%3E%3C/svg%3E";
undefined}>
}>
Settings
```
## Without a logo, on the rail
A building stands in for the logo; the rail shows the button alone and opens the menu beside it.
```tsx
import { OrganizationMenu } from '@arbol-ai/ui';
undefined} />
```
## Loading
```tsx
import { OrganizationMenu } from '@arbol-ai/ui';
undefined} />
```
## Source
The examples above are [`src/blocks/organization-menu/organization-menu.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/organization-menu/organization-menu.examples.tsx), which the library's tests render too.
# OverlayActions
> The actions at the foot of a form in an overlay: Cancel (or Back, in steps) and the submit button, which shows the save running and submits with Ctrl+Enter from any field. FormDialog and FormSheet render them; use them alone for a form of your own.
Source: https://epidermis.getarbol.com/components/blocks/overlay-actions/ · Markdown: https://epidermis.getarbol.com/components/blocks/overlay-actions/index.md
## A plain form in an OverlayLayout
The form sits in the body and the actions in the footer, joined by the form’s id. Press Ctrl+Enter in the notes to submit.
```tsx
import { useId, useState } from 'react';
import { Button, Field, OverlayActions, OverlayLayout, OverlayTrigger, nativeFieldBox } from '@arbol-ai/ui';
const PayerExample = () => {
const formId = useId();
const [kind, setKind] = useState('EPS');
const [added, setAdded] = useState('');
return (
New payer
} title="New payer">
{(_close, leave) => (
)}
{added === '' ? null : `${added} payer added.`}
);
};
```
## Source
The examples above are [`src/blocks/overlay-actions/overlay-actions.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/overlay-actions/overlay-actions.examples.tsx), which the library's tests render too.
# OverlayLayout
> The standard overlay that is not a form: a title and description with the close button, a body that scrolls, and a footer that stays in view. A record in a sheet, settings that apply at once, or an alert-sized card that is not a question.
Source: https://epidermis.getarbol.com/components/blocks/overlay-layout/ · Markdown: https://epidermis.getarbol.com/components/blocks/overlay-layout/index.md
## A record in a sheet
Opened from a trigger; the main action is last in the footer, and focus returns to the button.
```tsx
import { Button, OverlayLayout, OverlayTrigger } from '@arbol-ai/ui';
Ana Torres
Done
Open profile
>
}
kind="sheet"
title="Ana Torres"
>
Phone
+57 300 555 0101
Last visit
October 28
```
## Settings that apply at once
Nothing is lost on closing, so it never asks; Reset sits at the start of the footer.
```tsx
import { useState } from 'react';
import { Button, OverlayLayout, OverlayTrigger, SettingToggle } from '@arbol-ai/ui';
const BoardSettingsExample = () => {
const [showWeekends, setShowWeekends] = useState(false);
const [startNow, setStartNow] = useState(true);
return (
Board settings
Done}
footerStart={
{
setShowWeekends(false);
setStartNow(true);
}}
variant="ghost"
>
Reset everything
}
kind="sheet"
title="Board settings"
>
);
};
```
## An alert-sized card
kind="alert" with role="dialog": a small card on every screen that a click outside dismisses, since it asks nothing.
```tsx
import { Button, OverlayLayout, OverlayTrigger } from '@arbol-ai/ui';
Lunch break
Release the time}
kind="alert"
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the overlay's role prop, not an element's
role="dialog"
title="Lunch break"
>
Nobody can book in this time.
```
## Source
The examples above are [`src/blocks/overlay-layout/overlay-layout.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/overlay-layout/overlay-layout.examples.tsx), which the library's tests render too.
# PeriodPicker
> The period a report or dashboard reads: presets such as the last 7 days or this month, then a custom range. A report always has a period, so there is nothing to clear; the popover states the exact days and, while comparing, the previous period. The value is plain days the app keeps in the URL.
Source: https://epidermis.getarbol.com/components/blocks/period-picker/ · Markdown: https://epidermis.getarbol.com/components/blocks/period-picker/index.md
## A report of what already happened
The calendar ends today; the page starts on the last 7 days (presetRange(7)) and can compare them with the 7 days before.
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import { useState } from 'react';
import { PeriodPicker, presetRange } from '@arbol-ai/ui';
const PastExample = () => {
const [period, setPeriod] = useState(() => presetRange(7));
const [isComparing, setIsComparing] = useState(true);
return (
);
};
```
## An agenda that also looks ahead
Scheduling reports add the days ahead, for appointments still to come.
```tsx
import { useState } from 'react';
import { PeriodPicker, presetRange } from '@arbol-ai/ui';
const AheadExample = () => {
const [period, setPeriod] = useState(() => presetRange({ next: 7 }));
return (
);
};
```
## Source
The examples above are [`src/blocks/period-picker/period-picker.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/period-picker/period-picker.examples.tsx), which the library's tests render too.
# Product mark
> Each of Arbol’s products as its brand tile, the same in every app. It is decoration: write the product’s name beside it. In the sidebar, productIcon(product) makes it an entry’s icon for the apps that open in a new tab.
Source: https://epidermis.getarbol.com/components/blocks/product-mark/ · Markdown: https://epidermis.getarbol.com/components/blocks/product-mark/index.md
## Products
```tsx
import { ProductMark } from '@arbol-ai/ui';
import type { ArbolProduct } from '@arbol-ai/ui';
const PRODUCTS: { product: ArbolProduct; name: string }[] = [
{ product: 'raiz', name: 'Raíz' },
{ product: 'agenda', name: 'Agenda' },
{ product: 'ambar', name: 'Ámbar' },
{ product: 'account', name: 'Account' },
];
{PRODUCTS.map(({ name, product }) => (
{name}
))}
```
## Sizes
sm (20px) for lists and menus, md (32px) by default, lg (40px) and xl (48px) for headers.
```tsx
import { ProductMark } from '@arbol-ai/ui';
```
## Stacked
A ring in the background’s color sets each tile apart.
```tsx
import { ProductMark } from '@arbol-ai/ui';
import type { ArbolProduct } from '@arbol-ai/ui';
const PRODUCTS: { product: ArbolProduct; name: string }[] = [
{ product: 'raiz', name: 'Raíz' },
{ product: 'agenda', name: 'Agenda' },
{ product: 'ambar', name: 'Ámbar' },
{ product: 'account', name: 'Account' },
];
{PRODUCTS.slice(0, 3).map(({ product }) => (
))}
Raíz, Agenda and Ámbar
```
## Source
The examples above are [`src/blocks/product-mark/product-mark.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/product-mark/product-mark.examples.tsx), which the library's tests render too.
# Property list
> A record’s properties as labels and values: stacked for a record’s data or an aside, inline for dates and amounts. An empty value shows a dash, read as “Not set”.
Source: https://epidermis.getarbol.com/components/blocks/property-list/ · Markdown: https://epidermis.getarbol.com/components/blocks/property-list/index.md
## A patient’s data
Stacked, in up to three columns as the width allows.
```tsx
import { Badge, Link, Property, PropertyList } from '@arbol-ai/ui';
CC 1.020.304.050
May 4, 1990 · 36 years
+57 300 555 0101
Sura EPS
Active
```
## An invoice’s totals
Inline: the label at the start, the value at the end, rows divided by hairlines.
```tsx
import { Property, PropertyList } from '@arbol-ai/ui';
Nov 1, 2026
Nov 15, 2026
$1,042,017
$1,240,000
```
## Source
The examples above are [`src/blocks/property-list/property-list.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/property-list/property-list.examples.tsx), which the library's tests render too.
# Record header
> The head of a record’s page, from data: its avatar or icon, its name as the page’s title with its status, a line of facts, and its actions: the common ones in outline, the next step filled and the rest in a ⋯ menu. RecordPage draws it; use it alone in a page laid out by hand.
Source: https://epidermis.getarbol.com/components/blocks/record-header/ · Markdown: https://epidermis.getarbol.com/components/blocks/record-header/index.md
## Patient
Facts come as a list: empty ones are skipped and the rest set apart by dots. On a phone, outline actions keep only their icon.
```tsx
import { ArchiveIcon, CalendarPlusIcon, ChatCircleTextIcon, FileArrowDownIcon, PhoneIcon } from '@phosphor-icons/react';
import { Avatar, RecordHeader } from '@arbol-ai/ui';
, href: 'tel:+573005550101' },
{
label: 'WhatsApp',
icon: ,
href: 'https://wa.me/573005550101',
target: '_blank',
},
]}
avatar={ }
menu={[
{ label: 'Export record', icon: },
{ label: 'Archive patient', icon: , isDestructive: true },
]}
menuLabel="More actions for Laura Gómez"
meta={['CC 1.020.304.050', '34 years', null, 'Sura EPS']}
primaryAction={{ label: 'Book', icon: , href: '#/record-header' }}
title="Laura Gómez"
/>
```
## Service with a status
An icon tile stands in for the avatar; the status sits beside the name.
```tsx
import { StethoscopeIcon } from '@phosphor-icons/react';
import { Badge, RecordHeader } from '@arbol-ai/ui';
}
meta={['General medicine', '20 minutes', '3 active agendas']}
primaryAction={{ label: 'Book', href: '#/record-header' }}
status={Active }
title="General consultation"
/>
```
## Renamed in place, with a pager
onTitleChange makes the name editable: press it, Enter saves, Esc puts it back. K and J move to the previous and next campaign.
```tsx
import { PauseIcon, TrashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Badge, Link, RecordHeader } from '@arbol-ai/ui';
const CampaignHeader = () => {
const [name, setName] = useState('Spring check-up reminders');
return (
}]}
back={
Campaigns
}
menu={[{ label: 'Delete campaign', icon: , isDestructive: true }]}
menuLabel={`More actions for ${name}`}
meta="WhatsApp · 1,240 contacts · running since Monday"
onTitleChange={setName}
pager={{
label: 'Move between campaigns',
nextHref: '#/record-header',
position: 3,
previousHref: '#/record-header',
total: 12,
}}
status={Running }
title={name}
titleLabel="Campaign name"
/>
);
};
```
## Source
The examples above are [`src/blocks/record-header/record-header.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/record-header/record-header.examples.tsx), which the library's tests render too.
# Record layout
> A record’s page body: the main column, and an aside with its properties that stays in view as the page scrolls. Below xl the aside folds into a disclosure above the main column.
Source: https://epidermis.getarbol.com/components/blocks/record-layout/ · Markdown: https://epidermis.getarbol.com/components/blocks/record-layout/index.md
## Campaign
Narrow the window below 1280px: the settings fold into “Campaign details”.
```tsx
import { PencilSimpleIcon } from '@phosphor-icons/react';
import { ButtonLink, Card, CardAction, CardContent, CardHeader, CardTitle, Link, Property, PropertyList, RecordLayout } from '@arbol-ai/ui';
Settings
Edit
Front desk
+57 601 555 0101
Weekdays, 8:00 to 18:00
Two, an hour apart
}
asideLabel="Campaign details"
>
812 reached · 164 booked · 66% answered
The recipients table goes here.
```
## Source
The examples above are [`src/blocks/record-layout/record-layout.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/record-layout/record-layout.examples.tsx), which the library's tests render too.
# Record page
> The page of one record, finished: pass the header’s data, the sections as items and the properties as label/value items, and fill in the content. Sections are tabs of the page (each with its content) or links to their own URLs (with the route’s content as children).
Source: https://epidermis.getarbol.com/components/blocks/record-page/ · Markdown: https://epidermis.getarbol.com/components/blocks/record-page/index.md
## Patient: tabs of the page
The tab lives in the app’s state (?tab=); the Data tab keeps its draft while another shows.
```tsx
import { ArchiveIcon, CalendarPlusIcon, PhoneIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Notice, RecordPage, SettingGroup, SettingToggle } from '@arbol-ai/ui';
const PatientPage = () => {
const [tab, setTab] = useState('appointments');
return (
, href: 'tel:+573005550101' }]}
avatar={ }
className="px-0 md:px-0"
menu={[{ label: 'Archive patient', icon: , isDestructive: true }]}
menuLabel="More actions for Laura Gómez"
meta={['CC 1.020.304.050', '34 years', 'Sura EPS']}
onSectionChange={setTab}
primaryAction={{ label: 'Book', icon: , href: '#/record-page' }}
section={tab}
sections={[
{
id: 'appointments',
label: 'Appointments',
count: 12,
content: Monday 14 · Annual check-up with Dr. Ruiz
,
},
{
id: 'notes',
label: 'Notes',
count: 3,
content: Prefers morning appointments. Allergic to penicillin.
,
},
{
id: 'profile',
label: 'Data',
keepMounted: true,
content: (
),
},
]}
sectionsLabel="Patient sections"
summary={Next visit: Monday 14 at 9:30, with Dr. Ruiz. }
title="Laura Gómez"
/>
);
};
```
## Campaign: properties beside the content
Narrow the window below 1280px: the properties fold into “Campaign details” above the content.
```tsx
import { PauseIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { Badge, RecordPage } from '@arbol-ai/ui';
}]}
className="px-0 md:px-0"
menu={[{ label: 'Delete campaign', icon: , isDestructive: true }]}
menuLabel="More actions for Spring reminders"
meta="WhatsApp · started Monday"
onTitleChange={() => undefined}
properties={[
{ label: 'Agent', value: 'Front desk' },
{ label: 'Line', value: '+57 601 555 0101' },
{ label: 'Contact hours', value: 'Weekdays, 8:00 to 18:00' },
{ label: 'End', value: null },
]}
propertiesAction={{ label: 'Edit settings', icon: , href: '#/record-page' }}
propertiesLabel="Campaign details"
status={Running }
title="Spring reminders"
titleLabel="Campaign name"
>
812 reached · 164 booked · 66% answered
The recipients table goes here.
```
## Professional: sections at their own URLs
Each section is a page; the router’s content for it is the children. Here a small router in memory follows the links.
```tsx
import { CalendarBlankIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { RouterProvider } from 'react-aria-components';
import { Avatar, RecordPage } from '@arbol-ai/ui';
const PROFESSIONAL_SECTIONS = {
schedule: 'Weekdays, 7:00 to 13:00. Closed on holidays.',
services: 'General consultation, follow-up visit.',
profile: 'Internal medicine · Room 4.',
} as const;
const ProfessionalPage = () => {
const [section, setSection] = useState('schedule');
return (
{
const next = href.split('/').at(-1);
if (next === 'schedule' || next === 'services' || next === 'profile') {
setSection(next);
}
}}
>
, href: '#/record-page/agenda' }]}
avatar={ }
className="px-0 md:px-0"
meta={['Internal medicine', 'Room 4']}
section={section}
sections={[
{
id: 'schedule',
label: 'Schedule',
href: '#/record-page/schedule',
},
{
id: 'services',
label: 'Services',
href: '#/record-page/services',
attention: 'No services yet',
},
{ id: 'profile', label: 'Profile', href: '#/record-page/profile' },
]}
sectionsLabel="Professional sections"
title="Dra. Ana Ruiz"
>
{PROFESSIONAL_SECTIONS[section]}
);
};
```
## Source
The examples above are [`src/blocks/record-page/record-page.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/record-page/record-page.examples.tsx), which the library's tests render too.
# Record pager
> Moves from one record to the next without going back to the list: up and down arrows, the record’s place, and K and J on the keyboard. Arrows are links for records with their own page, or callbacks for records shown in a sheet.
Source: https://epidermis.getarbol.com/components/blocks/record-pager/ · Markdown: https://epidermis.getarbol.com/components/blocks/record-pager/index.md
## Between pages
Each arrow is a link; the first record has no previous one.
```tsx
import { RecordPager } from '@arbol-ai/ui';
```
## In place
Press K or J (outside a field) to move through the recipients.
```tsx
import { useState } from 'react';
import { RecordPager } from '@arbol-ai/ui';
const RECIPIENTS = ['Laura Gómez', 'Andrés Ruiz', 'Marta Díaz', 'Jorge Peña'];
const RecipientPager = () => {
const [index, setIndex] = useState(0);
return (
{
setIndex(index + 1);
}
: undefined
}
onPrevious={
index > 0
? () => {
setIndex(index - 1);
}
: undefined
}
position={index + 1}
total={RECIPIENTS.length}
/>
{RECIPIENTS[index]}
);
};
```
## Source
The examples above are [`src/blocks/record-pager/record-pager.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/record-pager/record-pager.examples.tsx), which the library's tests render too.
# ReportPage
> A finished report or dashboard page from data: the question and its exact days, the period (presets, a custom range and the comparison with the previous period), the report’s filters and its export; then the headline figures, their reading and the chart cards, each with its loading and empty states. The app passes data and callbacks; every part is also public.
Source: https://epidermis.getarbol.com/components/blocks/report-page/ · Markdown: https://epidermis.getarbol.com/components/blocks/report-page/index.md
## An overview
The period starts on the last 7 days and compares with the 7 before; turning the comparison off hides every change at once.
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import { useState } from 'react';
import { presetRange } from '@arbol-ai/ui';
import type { Kpi } from '@arbol-ai/ui';
import { ReportPage } from '@arbol-ai/ui/charts';
import type { ReportChart } from '@arbol-ai/ui/charts';
const OVERVIEW_KPIS: Kpi[] = [
{ id: 'live', label: 'Live now', value: 18, isLive: true, hint: '12 by phone · 6 on WhatsApp' },
{
id: 'rate',
label: 'Success rate',
value: 0.81,
formatOptions: { style: 'percent' },
change: 3.2,
changeUnit: 'points',
info: 'Conversations that reached their goal, out of every conversation with a result.',
},
{
id: 'review',
label: 'To review',
value: 23,
change: -0.12,
changeTone: 'inverse',
link: { href: '#/report-page', label: 'Review them' },
},
];
const DAILY = [312, 298, 341, 356, 330, 142, 118];
const DAILY_BEFORE = [290, 305, 322, 318, 301, 130, 121];
const CHANNELS = [
{ value: 'voice', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'web', label: 'Web chat' },
];
const OverviewExample = () => {
const [period, setPeriod] = useState(() => presetRange(7));
const [isComparing, setIsComparing] = useState(true);
const [channel, setChannel] = useState(null);
const zone = getLocalTimeZone();
const charts: ReportChart[] = [
{
id: 'daily',
title: 'Conversations',
span: 'wide',
value: 1897,
change: 0.06,
chart: {
kind: 'trend',
data: DAILY.map((value, index) => ({
date: period.start.add({ days: index }).toDate(zone),
value,
previous: DAILY_BEFORE[index] ?? null,
})),
},
},
{
id: 'outcomes',
title: 'How they ended',
span: 'narrow',
chart: {
kind: 'breakdown',
showShare: true,
items: [
{ id: 'success', label: 'Resolved', value: 1203, color: 'bg-chart-1', href: '#/report-page' },
{ id: 'failure', label: 'Not resolved', value: 284, color: 'bg-red-9', href: '#/report-page' },
{ id: 'unknown', label: 'Undecided', value: 410, color: 'bg-gray-6' },
],
},
},
];
return (
undefined}
onPeriodChange={setPeriod}
period={period}
reading="81% of the conversations with a result reached their goal, 3 points more than the week before."
title="Overview"
/>
);
};
```
## A report that narrows its list
A scheduling report: the days ahead among the presets, and reasons that filter the list below.
```tsx
import { useState } from 'react';
import { presetRange } from '@arbol-ai/ui';
import { ReportPage } from '@arbol-ai/ui/charts';
const REASONS = [
{ id: 'forgot', label: 'Forgot', value: 18, caption: 'no-show', color: 'bg-red-9' },
{ id: 'transport', label: 'No transport', value: 11, caption: 'no-show', color: 'bg-red-9' },
{ id: 'rescheduled', label: 'Rescheduled elsewhere', value: 7, caption: 'cancelled', color: 'bg-gray-a6' },
];
const NoShowsExample = () => {
const [period, setPeriod] = useState(() => presetRange(30));
const [reason, setReason] = useState(null);
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
return (
undefined}
onPeriodChange={setPeriod}
period={period}
presets={[7, 30, 90, 'this-month', 'last-month', { next: 7 }, { next: 30 }]}
reading="41 patients missed their appointment (3%) and 64 cancelled (5%). The most common reason: “Forgot”."
title="No-shows"
/>
);
};
```
## Loading, empty and failed
```tsx
import { CalendarXIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button, EmptyState, SegmentedControl, presetRange } from '@arbol-ai/ui';
import { ReportPage } from '@arbol-ai/ui/charts';
import type { ReportPageProps } from '@arbol-ai/ui/charts';
const STATES: Pick = {
title: 'Appointments',
kpis: [{ id: 'total', label: 'Appointments', value: 0, change: null }],
charts: [{ id: 'daily', title: 'Appointments per day', chart: { kind: 'trend', data: [] } }],
};
type ReportState = 'loading' | 'empty' | 'failed';
const StatesExample = () => {
const [period, setPeriod] = useState(() => presetRange(7));
const [state, setState] = useState('loading');
return (
{
setPeriod(presetRange(90));
}}
variant="outline"
>
See the last 90 days
}
description="Try a longer period."
icon={ }
title="No appointments in this period"
/>
) : undefined
}
error={
state === 'failed' ? 'We couldn’t load the report. Check your connection and try again.' : undefined
}
isLoading={state === 'loading'}
onPeriodChange={setPeriod}
onRetry={() => {
setState('loading');
}}
period={period}
warning={
state === 'failed'
? 'Counts stop at 10,000 appointments: narrow the period to see them all.'
: undefined
}
/>
);
};
```
## Source
The examples above are [`src/charts/report-page.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/report-page.examples.tsx), which the library's tests render too.
# ReportHeader
> The parts ReportPage is assembled from, for a page it does not fit. ReportHeader states the question and the exact days it covers, with the period and filters under it and the export at its end; ReportReading says what the figures mean in a sentence.
Source: https://epidermis.getarbol.com/components/blocks/report/ · Markdown: https://epidermis.getarbol.com/components/blocks/report/index.md
## A report’s header
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import { useState } from 'react';
import { ButtonLink, FilterSelect, PeriodPicker, ReportHeader, presetRange } from '@arbol-ai/ui';
const CHANNELS = [
{ value: 'voice', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
];
const HeaderExample = () => {
const [period, setPeriod] = useState(() => presetRange(7));
const [channels, setChannels] = useState([]);
const [isExporting, setIsExporting] = useState(false);
return (
See the conversations
}
description="How many conversations did the agents hold, and how did they end?"
exportLabel="Export CSV"
filters={
<>
>
}
isExporting={isExporting}
onExport={() => {
setIsExporting(true);
globalThis.setTimeout(() => {
setIsExporting(false);
}, 1200);
}}
period={period}
title="Conversations"
/>
);
};
```
## An index of reports
Only a title and a description: periods and exports belong to each report.
```tsx
import { ReportHeader } from '@arbol-ai/ui';
```
## A reading beside its figure
```tsx
import { ReportReading, Stat } from '@arbol-ai/ui';
41 patients missed their appointment (3%) and 64 cancelled (5%). The most common reason: “Forgot”.
```
## Source
The examples above are [`src/blocks/report/report.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/report/report.examples.tsx), which the library's tests render too.
# Route states
> What a page shows instead of itself: RouteErrorState reads the failure’s kind (offline, session ended, no access, not found, the server’s) and offers the ways out it allows; NotFoundState and NoAccessState name two of them; AppLoadingState is the screen while the app starts. RouterErrorState and RouterNotFoundState, from the router entry, plug them into TanStack Router.
Source: https://epidermis.getarbol.com/components/blocks/route-states/ · Markdown: https://epidermis.getarbol.com/components/blocks/route-states/index.md
## The server failed
A retry where retrying can help, and the way to the start.
```tsx
import { useState } from 'react';
import { RouteErrorState } from '@arbol-ai/ui';
const RetryExample = () => {
const [isRetrying, setIsRetrying] = useState(false);
return (
{
setIsRetrying(true);
setTimeout(() => {
setIsRetrying(false);
}, 1500);
}}
/>
);
};
```
## The session ended
```tsx
import { RouteErrorState } from '@arbol-ai/ui';
undefined}
/>
```
## Not found
```tsx
import { NotFoundState } from '@arbol-ai/ui';
```
## No access
The app decides who may see the page; the state says so, and who to ask.
```tsx
import { NoAccessState } from '@arbol-ai/ui';
```
## Offline
```tsx
import { RouteErrorState } from '@arbol-ai/ui';
undefined} />
```
## The app starting
```tsx
import { AppLoadingState } from '@arbol-ai/ui';
```
## With TanStack Router
The report fails on its first load; Try again loads it again. A path no route matches shows the not-found state. Each example runs its own router in memory.
```tsx
import { Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { useMemo } from 'react';
import { useLocale } from 'react-aria-components';
import { Link } from '@arbol-ai/ui';
import { RouterErrorState, RouterNotFoundState, RouterUIProvider } from '@arbol-ai/ui/router';
const createExampleRouter = () => {
let attempts = 0;
const rootRoute = createRootRoute({ component: ExampleRoot });
const reportRoute = createRoute({
component: () => 18 appointments today, 3 conversations waiting.
,
getParentRoute: () => rootRoute,
loader: () => {
attempts += 1;
if (attempts === 1) {
throw Object.assign(new Error('Service unavailable'), { status: 503 });
}
},
path: '/',
});
return createRouter({
defaultErrorComponent: (props) => ,
defaultNotFoundComponent: () => ,
history: createMemoryHistory({ initialEntries: ['/'] }),
routeTree: rootRoute.addChildren([reportRoute]),
});
};
const RouterExample = () => {
const router = useMemo(() => createExampleRouter(), []);
return ;
};
const ExampleRoot = () => {
const { locale } = useLocale();
return (
Report
Archive (no such page)
);
};
```
## Source
The examples above are [`src/blocks/route-states/route-states.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/route-states/route-states.examples.tsx), which the library's tests render too.
# Section nav
> Tabs that are pages: a record’s or a settings area’s sections, each at its own URL, as a navigation of links whose thumb slides to the current one. On phones they wrap into balanced rows. Use Tabs for sections inside one page.
Source: https://epidermis.getarbol.com/components/blocks/section-nav/ · Markdown: https://epidermis.getarbol.com/components/blocks/section-nav/index.md
## A record’s sections
The dot says a section needs attention. In an app the router follows each link and passes the section on screen as value; here a small router in memory does.
```tsx
import { useState } from 'react';
import { RouterProvider } from 'react-aria-components';
import { SectionNav } from '@arbol-ai/ui';
import type { SectionNavItem } from '@arbol-ai/ui';
const PROFESSIONAL: SectionNavItem[] = [
{ id: 'schedule', label: 'Schedule', href: '#/section-nav/schedule', attention: 'No schedule yet' },
{ id: 'services', label: 'Services', href: '#/section-nav/services' },
{ id: 'appointments', label: 'Appointments', href: '#/section-nav/appointments' },
{ id: 'profile', label: 'Profile', href: '#/section-nav/profile' },
];
const Sections = ({ items, label }: { items: SectionNavItem[]; label: string }) => {
const [current, setCurrent] = useState(items[1]?.id ?? '');
return (
{
setCurrent(href.split('/').at(-1) ?? '');
}}
>
);
};
```
## Five sections
Narrow the window below 640px: a row of three, then a row of two.
```tsx
import { useState } from 'react';
import { RouterProvider } from 'react-aria-components';
import { SectionNav } from '@arbol-ai/ui';
import type { SectionNavItem } from '@arbol-ai/ui';
const BILLING: SectionNavItem[] = [
{ id: 'summary', label: 'Summary', href: '#/section-nav/summary' },
{ id: 'invoices', label: 'Invoices', href: '#/section-nav/invoices' },
{ id: 'payments', label: 'Payments', href: '#/section-nav/payments' },
{ id: 'movements', label: 'Movements', href: '#/section-nav/movements' },
{ id: 'profile', label: 'Billing profile', href: '#/section-nav/profile' },
];
const Sections = ({ items, label }: { items: SectionNavItem[]; label: string }) => {
const [current, setCurrent] = useState(items[1]?.id ?? '');
return (
{
setCurrent(href.split('/').at(-1) ?? '');
}}
>
);
};
```
## Source
The examples above are [`src/blocks/section-nav/section-nav.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/section-nav/section-nav.examples.tsx), which the library's tests render too.
# SelectionBar
> The actions for the rows selected in a list: how many are selected, what can be done with all of them at once and a way to clear the selection, floating at the bottom of the screen while the list scrolls.
Source: https://epidermis.getarbol.com/components/blocks/selection-bar/ · Markdown: https://epidermis.getarbol.com/components/blocks/selection-bar/index.md
## Bulk actions
Select rows with their boxes, or all of them from the header; the bar leaves when none is.
```tsx
import { ArchiveIcon, DownloadSimpleIcon } from '@phosphor-icons/react';
import type { ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
import type { Selection } from 'react-aria-components';
import { Button, DataTable, SelectionBar } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Invitation {
id: string;
email: string;
role: string;
}
const INVITATIONS: Invitation[] = [
{ id: 'i1', email: 'ana.torres@example.com', role: 'Front desk' },
{ id: 'i2', email: 'luis.gomez@example.com', role: 'Admin' },
{ id: 'i3', email: 'marta.ruiz@example.com', role: 'Front desk' },
{ id: 'i4', email: 'carlos.pena@example.com', role: 'Viewer' },
];
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'email', header: 'Email', meta: { fill: true } },
{ accessorKey: 'role', header: 'Role', size: 140 },
];
const BulkExample = () => {
const [selected, setSelected] = useState(new Set(['i2', 'i3']));
return (
invitation.id}
layout="fixed"
onSelectionChange={setSelected}
selectedKeys={selected}
selectionMode="multiple"
/>
{
setSelected(new Set());
}}
>
Export
Revoke
);
};
```
## Source
The examples above are [`src/blocks/selection-bar/selection-bar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/selection-bar/selection-bar.examples.tsx), which the library's tests render too.
# Settings card
> One topic of a settings or record page as a card: icon, title, description and one action at its head, then its fields, and Save and Cancel at its foot when it edits in place.
Source: https://epidermis.getarbol.com/components/blocks/settings-card/ · Markdown: https://epidermis.getarbol.com/components/blocks/settings-card/index.md
## Topic with fields
```tsx
import { BuildingsIcon } from '@phosphor-icons/react';
import { SettingsCard, TextField } from '@arbol-ai/ui';
}
title="Company"
>
```
## Edited in place
Edit turns the card into a form; Save and Cancel sit at its foot.
```tsx
import { IdentificationCardIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button, Property, PropertyList, SettingsCard, TextField } from '@arbol-ai/ui';
const PatientData = () => {
const [isEditing, setIsEditing] = useState(false);
const [phone, setPhone] = useState('+57 300 555 0101');
const [draft, setDraft] = useState(phone);
return (
{
setDraft(phone);
setIsEditing(true);
}}
size="sm"
variant="outline"
>
Edit
)
}
className="max-w-xl"
description="Filled in when booking; correct them here."
footer={
isEditing ? (
<>
{
setPhone(draft);
setIsEditing(false);
}}
size="sm"
>
Save changes
{
setIsEditing(false);
}}
size="sm"
variant="ghost"
>
Cancel
>
) : undefined
}
icon={ }
title="Patient data"
>
{isEditing ? (
) : (
CC 1.020.304.050
{phone}
)}
);
};
```
## Source
The examples above are [`src/blocks/settings-card/settings-card.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/settings-card/settings-card.examples.tsx), which the library's tests render too.
# Settings links
> A settings landing: groups of cards, each a link to one settings page with its icon, what it is for and how it stands today. On a phone each group is one compact list.
Source: https://epidermis.getarbol.com/components/blocks/settings-links/ · Markdown: https://epidermis.getarbol.com/components/blocks/settings-links/index.md
## Settings landing
An amber status says a page needs attention.
```tsx
import { BuildingsIcon, EnvelopeSimpleIcon, ListChecksIcon, StethoscopeIcon, UsersThreeIcon } from '@phosphor-icons/react';
import { SettingsLink, SettingsLinkGroup } from '@arbol-ai/ui';
}
status="Clínica Norte · 5 functions"
title="Institution"
/>
}
status="4 agendas · 1 without hours"
statusTone="warning"
title="Professionals and rooms"
/>
}
status="12 active services"
title="Services"
/>
}
title="Booking details"
/>
}
title="Notifications"
/>
```
## A lone link
A group without a title holds a link to another settings page.
```tsx
import { PlugsConnectedIcon } from '@phosphor-icons/react';
import { SettingsLink, SettingsLinkGroup } from '@arbol-ai/ui';
}
title="Integrations"
/>
```
## Source
The examples above are [`src/blocks/settings-links/settings-links.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/settings-links/settings-links.examples.tsx), which the library's tests render too.
# Settings page
> A settings page, finished: pass its sections as items (each with its rows), its draft’s save and the router’s unsaved-changes guard. The sections are one form that the save bar at its foot saves together; settings that apply at once need no save.
Source: https://epidermis.getarbol.com/components/blocks/settings-page/ · Markdown: https://epidermis.getarbol.com/components/blocks/settings-page/index.md
## A draft saved together
Change the greeting: the save bar appears. Save checks the fields first (clear the greeting to see it refuse).
```tsx
import { useState } from 'react';
import { SettingRow, SettingToggle, SettingsPage, TextField } from '@arbol-ai/ui';
const SAVED_GREETING = 'Hi, this is Clínica Norte. How can I help?';
const AgentSettings = () => {
const [saved, setSaved] = useState({ greeting: SAVED_GREETING, endSilentCalls: true });
const [draft, setDraft] = useState(saved);
const [isSaved, setIsSaved] = useState(false);
return (
{
setDraft(saved);
},
onSave: () => {
setSaved(draft);
setIsSaved(true);
},
}}
sections={[
{
id: 'greeting',
title: 'Greeting',
description: 'What callers hear first.',
content: (
{
setDraft({ ...draft, greeting });
setIsSaved(false);
}}
value={draft.greeting}
/>
),
},
{
id: 'calls',
title: 'Calls',
content: (
{
setDraft({ ...draft, endSilentCalls });
setIsSaved(false);
}}
/>
),
},
]}
title="Agent settings"
/>
);
};
```
## Settings that apply at once
No save: each control applies as it changes.
```tsx
import { useState } from 'react';
import { SegmentedControl, SettingRow, SettingsPage } from '@arbol-ai/ui';
const Preferences = () => {
const [language, setLanguage] = useState('es');
return (
),
},
]}
title="Profile"
/>
);
};
```
## An area with tabs and a preview
The area’s pages as tabs, a notice first, and what the settings change beside them.
```tsx
import { UserPlusIcon } from '@phosphor-icons/react';
import { Notice, SettingRow, SettingsPage, TextField } from '@arbol-ai/ui';
Welcome to Clínica Norte
}
primaryAction={{ label: 'Invite people', icon: , href: '#/settings-page' }}
sections={[
{
id: 'identity',
title: 'Identity',
description: 'How the organization appears in every app.',
content: (
),
},
]}
summary={Only administrators change these settings. }
tabs={{
items: [
{ id: 'organization', label: 'Organization', href: '#/settings-page' },
{ id: 'team', label: 'Team', href: '#/settings-page' },
{ id: 'billing', label: 'Billing', href: '#/settings-page' },
],
label: 'Settings pages',
value: 'organization',
}}
title="Organization"
/>
```
## Source
The examples above are [`src/blocks/settings-page/settings-page.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/settings-page/settings-page.examples.tsx), which the library's tests render too.
# Charts
> The charts components of @arbol-ai/ui: BarChart, CalendarHeatmap, Chart, ChartPanel, DonutChart, FunnelChart, GaugeChart, Sparkline, TrendChart.
Source: https://epidermis.getarbol.com/components/charts/ · Markdown: https://epidermis.getarbol.com/components/charts/index.md
- [BarChart](https://epidermis.getarbol.com/components/charts/bar-chart/): Values compared across categories: series side by side, stacked into one bar per category, or as horizontal bars for long names. Give each series the color its legend shows.
- [CalendarHeatmap](https://epidermis.getarbol.com/components/charts/calendar-heatmap/): A long period as a calendar: one cell per day, deeper where the day counted more, with weeks starting on the reader’s first day.
- [Chart](https://epidermis.getarbol.com/components/charts/chart/): The ECharts host behind every recipe, for a figure no recipe draws: an accessible image whose option takes its colors from useChartTokens and its look from the shared pieces. ECharts loads with the first chart.
- [ChartPanel](https://epidermis.getarbol.com/components/charts/chart-panel/): The card a dashboard chart sits in: eyebrow, title, the headline figure with its change, a note, the legend at the end of the header, then the chart. ChartLegend names the series in their theme colors; ChartEmpty says there is nothing to draw.
- [DonutChart](https://epidermis.getarbol.com/components/charts/donut-chart/): Part of a whole, with the total in the middle. Name the slices with a ChartLegend beside it; the tooltip gives each value and share.
- [FunnelChart](https://epidermis.getarbol.com/components/charts/funnel-chart/): Where a process narrows, stage by stage, widest first. Each stage shows its value; the tooltip adds its share of the first stage.
- [GaugeChart](https://epidermis.getarbol.com/components/charts/gauge-chart/): A rate from 0 to 1 as a dial, printed as a percentage in the middle. With no rate (nothing measured) it shows the empty note.
- [Sparkline](https://epidermis.getarbol.com/components/charts/sparkline/): A metric’s recent shape beside its figure, without axes or tooltip. An outcome color tells good news from bad.
- [TrendChart](https://epidermis.getarbol.com/components/charts/trend-chart/): One metric over a period, against the period before it. Use it for daily or weekly figures; the open-ended variant dashes today, which is still being counted.
# BarChart
> Values compared across categories: series side by side, stacked into one bar per category, or as horizontal bars for long names. Give each series the color its legend shows.
Source: https://epidermis.getarbol.com/components/charts/bar-chart/ · Markdown: https://epidermis.getarbol.com/components/charts/bar-chart/index.md
## Stacked by status
```tsx
import { BarChart, ChartLegend, ChartPanel } from '@arbol-ai/ui/charts';
import type { BarSeries } from '@arbol-ai/ui/charts';
const DAYS = ['Nov 2', 'Nov 3', 'Nov 4', 'Nov 5', 'Nov 6', 'Nov 7'];
const BY_STATUS: BarSeries[] = [
{ label: 'Attended', values: [38, 42, 35, 44, 40, 18], color: 'chart-positive' },
{ label: 'Cancelled', values: [4, 6, 3, 5, 7, 2], color: 'chart-caution' },
{ label: 'No-show', values: [3, 2, 5, 1, 4, 1], color: 'chart-negative' },
];
({ label, color }))} />}
note="Attended, cancelled and no-show appointments per day"
title="Appointments by status"
>
```
## This period beside the previous one
```tsx
import { BarChart } from '@arbol-ai/ui/charts';
```
## Horizontal, with rates
```tsx
import { BarChart } from '@arbol-ai/ui/charts';
const PERCENT: Intl.NumberFormatOptions = { style: 'percent' };
```
## Source
The examples above are [`src/charts/bar-chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/bar-chart.examples.tsx), which the library's tests render too.
# CalendarHeatmap
> A long period as a calendar: one cell per day, deeper where the day counted more, with weeks starting on the reader’s first day.
Source: https://epidermis.getarbol.com/components/charts/calendar-heatmap/ · Markdown: https://epidermis.getarbol.com/components/charts/calendar-heatmap/index.md
## A quarter of conversations
```tsx
import { CalendarHeatmap, ChartPanel } from '@arbol-ai/ui/charts';
import type { HeatmapDay } from '@arbol-ai/ui/charts';
const QUARTER: HeatmapDay[] = Array.from({ length: 91 }, (_, index) => {
const date = new Date(2026, 7, 9 + index);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return { date, value: isWeekend ? (index % 5) * 3 : 40 + ((index * 17) % 37) };
});
```
## Source
The examples above are [`src/charts/calendar-heatmap.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/calendar-heatmap.examples.tsx), which the library's tests render too.
# ChartPanel
> The card a dashboard chart sits in: eyebrow, title, the headline figure with its change, a note, the legend at the end of the header, then the chart. ChartLegend names the series in their theme colors; ChartEmpty says there is nothing to draw.
Source: https://epidermis.getarbol.com/components/charts/chart-panel/ · Markdown: https://epidermis.getarbol.com/components/charts/chart-panel/index.md
## A dashboard row
```tsx
import { TrendDelta } from '@arbol-ai/ui';
import { ChartLegend, ChartPanel, DonutChart, TrendChart } from '@arbol-ai/ui/charts';
const WEEK = [312, 298, 341, 356, 330, 142, 118].map((value, index) => ({
date: new Date(2026, 10, 1 + index),
value,
previous: [290, 305, 322, 318, 301, 130, 121][index] ?? null,
}));
}
title="Conversations"
trend={ }
value={1897}
>
```
## Loading
The figure and the chart each hold their place until the report arrives.
```tsx
import { ChartPanel, TrendChart } from '@arbol-ai/ui/charts';
```
## Nothing to draw yet
```tsx
import { BarChart, ChartEmpty, ChartPanel } from '@arbol-ai/ui/charts';
The campaign has not sent its first message yet
```
## Source
The examples above are [`src/charts/chart-panel.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/chart-panel.examples.tsx), which the library's tests render too.
# Chart
> The ECharts host behind every recipe, for a figure no recipe draws: an accessible image whose option takes its colors from useChartTokens and its look from the shared pieces. ECharts loads with the first chart.
Source: https://epidermis.getarbol.com/components/charts/chart/ · Markdown: https://epidermis.getarbol.com/components/charts/chart/index.md
## A custom option in the house style
```tsx
import { useMemo } from 'react';
import { Chart, chartGrid, chartTextStyle, chartTooltip, quietCategoryAxis, quietValueAxis, tooltipRow, useChartTokens } from '@arbol-ai/ui/charts';
import type { ChartOption } from '@arbol-ai/ui/charts';
const HOURS = ['8:00', '9:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00'];
const CALLS = [18, 42, 57, 51, 33, 24, 39, 46, 40, 21];
const CallsByHour = () => {
const tokens = useChartTokens();
const option = useMemo(
() => ({
grid: chartGrid,
textStyle: chartTextStyle,
tooltip: chartTooltip(tokens, {
trigger: 'axis',
formatter: (params) => {
const index = [params].flat()[0]?.dataIndex ?? 0;
return tooltipRow('var(--chart-2)', HOURS[index] ?? '', String(CALLS[index] ?? 0));
},
}),
xAxis: quietCategoryAxis(tokens, HOURS),
yAxis: quietValueAxis(tokens, String, { isInteger: true }),
series: [{ type: 'bar', data: CALLS, barMaxWidth: 28, itemStyle: { color: tokens['chart-2'] } }],
}),
[tokens]
);
return (
);
};
```
## Loading and empty
A placeholder holds the space while data loads; a chart with nothing to draw says so.
```tsx
import { Chart } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/charts/chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/chart.examples.tsx), which the library's tests render too.
# DonutChart
> Part of a whole, with the total in the middle. Name the slices with a ChartLegend beside it; the tooltip gives each value and share.
Source: https://epidermis.getarbol.com/components/charts/donut-chart/ · Markdown: https://epidermis.getarbol.com/components/charts/donut-chart/index.md
## Appointments by status
```tsx
import { ChartLegend, ChartPanel, DonutChart } from '@arbol-ai/ui/charts';
import type { DonutSlice } from '@arbol-ai/ui/charts';
const BY_STATUS: DonutSlice[] = [
{ label: 'Attended', value: 182, color: 'chart-positive' },
{ label: 'Rescheduled', value: 31, color: 'chart-2' },
{ label: 'Cancelled', value: 24, color: 'chart-caution' },
{ label: 'No-show', value: 11, color: 'chart-negative' },
];
({ label, color }))} />}
title="Appointments by status"
>
```
## Source
The examples above are [`src/charts/donut-chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/donut-chart.examples.tsx), which the library's tests render too.
# FunnelChart
> Where a process narrows, stage by stage, widest first. Each stage shows its value; the tooltip adds its share of the first stage.
Source: https://epidermis.getarbol.com/components/charts/funnel-chart/ · Markdown: https://epidermis.getarbol.com/components/charts/funnel-chart/index.md
## From contact to visit
```tsx
import { ChartPanel, FunnelChart } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/charts/funnel-chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/funnel-chart.examples.tsx), which the library's tests render too.
# GaugeChart
> A rate from 0 to 1 as a dial, printed as a percentage in the middle. With no rate (nothing measured) it shows the empty note.
Source: https://epidermis.getarbol.com/components/charts/gauge-chart/ · Markdown: https://epidermis.getarbol.com/components/charts/gauge-chart/index.md
## Success rate
```tsx
import { ChartPanel, GaugeChart } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/charts/gauge-chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/gauge-chart.examples.tsx), which the library's tests render too.
# Sparkline
> A metric’s recent shape beside its figure, without axes or tooltip. An outcome color tells good news from bad.
Source: https://epidermis.getarbol.com/components/charts/sparkline/ · Markdown: https://epidermis.getarbol.com/components/charts/sparkline/index.md
## Beside a figure
```tsx
import { Sparkline } from '@arbol-ai/ui/charts';
```
## Source
The examples above are [`src/charts/sparkline.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/sparkline.examples.tsx), which the library's tests render too.
# TrendChart
> One metric over a period, against the period before it. Use it for daily or weekly figures; the open-ended variant dashes today, which is still being counted.
Source: https://epidermis.getarbol.com/components/charts/trend-chart/ · Markdown: https://epidermis.getarbol.com/components/charts/trend-chart/index.md
## Current against previous period
```tsx
import { ChartLegend, ChartPanel, TrendChart } from '@arbol-ai/ui/charts';
import type { TrendPoint } from '@arbol-ai/ui/charts';
const REACHED = [182, 205, 197, 240, 228, 96, 74, 210, 236, 251, 244, 262, 118, 131];
const BEFORE = [170, 188, 201, 215, 209, 88, 70, 195, 214, 220, 231, 226, 101, 97];
const DAYS: TrendPoint[] = REACHED.map((value, index) => ({
date: new Date(2026, 9, 25 + index),
value,
previous: BEFORE[index] ?? null,
}));
}
note="2,674 contacts reached"
title="Contacts reached"
>
```
## Daily bars and rates
Bars read as discrete daily amounts; formatOptions prints 0–1 rates as percentages.
```tsx
import { TrendChart } from '@arbol-ai/ui/charts';
import type { TrendPoint } from '@arbol-ai/ui/charts';
const REACHED = [182, 205, 197, 240, 228, 96, 74, 210, 236, 251, 244, 262, 118, 131];
const BEFORE = [170, 188, 201, 215, 209, 88, 70, 195, 214, 220, 231, 226, 101, 97];
const DAYS: TrendPoint[] = REACHED.map((value, index) => ({
date: new Date(2026, 9, 25 + index),
value,
previous: BEFORE[index] ?? null,
}));
const RATE: Intl.NumberFormatOptions = { style: 'percent', maximumFractionDigits: 1 };
({ date: day.date, value: day.value === null ? null : day.value / 300 }))}
formatOptions={RATE}
variant="line"
/>
```
## Source
The examples above are [`src/charts/trend-chart.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/charts/trend-chart.examples.tsx), which the library's tests render too.
# Collections
> The collections components of @arbol-ai/ui: Filter chips, Pick list, Tag group.
Source: https://epidermis.getarbol.com/components/collections/ · Markdown: https://epidermis.getarbol.com/components/collections/index.md
- [Filter chips](https://epidermis.getarbol.com/components/collections/filter-chips/): FilterChips: one choice among a few, above a list, with "All" first and optional counts; the chosen chip inverts. FilterToggle: one on/off filter such as including inactive records.
- [Pick list](https://epidermis.getarbol.com/components/collections/pick-list/): Choose many from a long list: an accent-insensitive search narrows it, "Select the matches" takes every match at once, and only the rows in view are rendered. Arrows, Home, End, Space and Ctrl+A work as in any list box.
- [Tag group](https://epidermis.getarbol.com/components/collections/tag-group/): A set of chips: labels, services, filters in force. Arrow keys move between tags; with onRemove each tag has a remove button (and Backspace removes the focused one); with selectionMode tags can be chosen.
# Filter chips
> FilterChips: one choice among a few, above a list, with "All" first and optional counts; the chosen chip inverts. FilterToggle: one on/off filter such as including inactive records.
Source: https://epidermis.getarbol.com/components/collections/filter-chips/ · Markdown: https://epidermis.getarbol.com/components/collections/filter-chips/index.md
## One choice
```tsx
import { useState } from 'react';
import { FilterChips } from '@arbol-ai/ui';
const StatusExample = () => {
const [status, setStatus] = useState<'confirmed' | 'pending' | 'cancelled' | null>(null);
return (
);
};
```
## An on/off filter
```tsx
import { useState } from 'react';
import { FilterToggle } from '@arbol-ai/ui';
const InactiveExample = () => {
const [showInactive, setShowInactive] = useState(false);
return ;
};
```
## Source
The examples above are [`src/components/filter-chips/filter-chips.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/filter-chips/filter-chips.examples.tsx), which the library's tests render too.
# Pick list
> Choose many from a long list: an accent-insensitive search narrows it, "Select the matches" takes every match at once, and only the rows in view are rendered. Arrows, Home, End, Space and Ctrl+A work as in any list box.
Source: https://epidermis.getarbol.com/components/collections/pick-list/ · Markdown: https://epidermis.getarbol.com/components/collections/pick-list/index.md
## Professionals by specialty
400 professionals in groups. Search "pediatria", then select the matches.
```tsx
import { useState } from 'react';
import { PickList } from '@arbol-ai/ui';
import type { PickListItem } from '@arbol-ai/ui';
const SPECIALTIES = ['General medicine', 'Pediatrics', 'Dermatology', 'Cardiology'];
const SITES = ['North clinic', 'South clinic', 'Downtown'];
const PROFESSIONALS: PickListItem[] = Array.from({ length: 400 }, (_, index) => ({
id: `pro_${index}`,
label: `Dr. ${['Ana', 'Luis', 'Marta', 'Jorge', 'Sofía'][index % 5]} ${['Ruiz', 'Torres', 'Gómez', 'Díaz'][index % 4]} ${index + 1}`,
meta: SITES[index % SITES.length],
group: SPECIALTIES[index % SPECIALTIES.length],
}));
const ProfessionalsExample = () => {
const [selected, setSelected] = useState>(new Set(['pro_0']));
return (
);
};
```
## Permissions
Identifiers in monospace; a disabled item stays visible and says why.
```tsx
import { useState } from 'react';
import { PickList } from '@arbol-ai/ui';
const PermissionsExample = () => {
const [selected, setSelected] = useState>(new Set());
return (
);
};
```
## Source
The examples above are [`src/components/pick-list/pick-list.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/pick-list/pick-list.examples.tsx), which the library's tests render too.
# Tag group
> A set of chips: labels, services, filters in force. Arrow keys move between tags; with onRemove each tag has a remove button (and Backspace removes the focused one); with selectionMode tags can be chosen.
Source: https://epidermis.getarbol.com/components/collections/tag-group/ · Markdown: https://epidermis.getarbol.com/components/collections/tag-group/index.md
## Removable
```tsx
import { useState } from 'react';
import { Tag, TagGroup } from '@arbol-ai/ui';
const RemovableExample = () => {
const [labels, setLabels] = useState([
{ id: 'vip', name: 'VIP' },
{ id: 'new', name: 'New patient' },
{ id: 'whatsapp', name: 'Prefers WhatsApp' },
]);
return (
{
setLabels((current) => current.filter((label) => !keys.has(label.id)));
}}
>
{(label) => {label.name} }
);
};
```
## Selectable
```tsx
import { Tag, TagGroup } from '@arbol-ai/ui';
Annual checkup
Vaccination
Lab results
```
## Source
The examples above are [`src/components/tag-group/tag-group.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/tag-group/tag-group.examples.tsx), which the library's tests render too.
# Data
> The data components of @arbol-ai/ui: Avatar, Badge, ColumnPicker, CountBadge, DataTable, FilterSelect, FoldingFilters, Kbd, Stat, StatusDot, Table, TanStack Query.
Source: https://epidermis.getarbol.com/components/data/ · Markdown: https://epidermis.getarbol.com/components/data/index.md
- [Avatar](https://epidermis.getarbol.com/components/data/avatar/): A person's picture or an organization's logo, with the name's initials (or an icon) while there is none or it fails to load. It is decoration: always write the name beside it.
- [Badge](https://epidermis.getarbol.com/components/data/badge/): A status pill: the state of a record in a word, in the color of what that state means. It is text, not a control. The apps' old variant names map to tones (see the component's documentation).
- [ColumnPicker](https://epidermis.getarbol.com/components/data/column-picker/): Shows, hides and reorders the columns of a table: a checkbox per column, a handle to drag it (or move it with the keyboard), and a reset. useColumnState keeps the state as plain ids the app can save, and gives DataTable its visibility and order.
- [CountBadge](https://epidermis.getarbol.com/components/data/count-badge/): A number in a small pill: the total beside a title, the filters in use, the records waiting in a queue. Use a Badge for a status in words.
- [DataTable](https://epidermis.getarbol.com/components/data/data-table/): A list driven by TanStack Table v9 and drawn by Table: sortable headers, column sizes, visibility and order, rows that open their record, an empty state, and a dimmed busy state while the next answer loads. Server-sorted lists pass manualSorting.
- [FilterSelect](https://epidermis.getarbol.com/components/data/filter-select/): One filter of a list: a button that says what it filters and what is applied, in the brand tint once it narrows the list, over a list of values with “All” first, counts and icons. Single or multiple selection; a search from eight options.
- [FoldingFilters](https://epidermis.getarbol.com/components/data/folding-filters/): A list’s filters folded behind one “Filters · n” button on a phone; from the sm breakpoint they sit in the filter row. Narrow the window to see it fold.
- [Kbd](https://epidermis.getarbol.com/components/data/kbd/): A key or a keyboard shortcut drawn as a key cap. It only shows the shortcut; the app handles the keys. Inside a menu item it is announced as the item’s shortcut.
- [Stat](https://epidermis.getarbol.com/components/data/stat/): Dashboard figures: Stat shows one number formatted for the locale with its change against the previous period (TrendDelta), a line of context, its definition behind an ⓘ and what it leads to; StatGrid lays them out by its own width; SummaryTile is a figure that also filters the list below it.
- [StatusDot](https://epidermis.getarbol.com/components/data/status-dot/): A small colored dot that says a status at a glance, optionally pulsing while something is live. Give it a label, visible or for screen readers only, so color is never the only sign.
- [Table](https://epidermis.getarbol.com/components/data/table/): Records in rows and columns, in a rounded frame. Arrow keys move through it, rows open with a click, Enter or a tap, columns sort, and rows can be selected. Use DataTable when TanStack Table drives the list.
- [TanStack Query](https://epidermis.getarbol.com/components/data/query-view/): The query entry: QueryView draws a query in the dashboard’s states, a skeleton or spinner before the first answer, an error with a retry, an empty state, and the data, dimmed while the next page loads. Each example has its own QueryClient and a pretend server.
# Avatar
> A person's picture or an organization's logo, with the name's initials (or an icon) while there is none or it fails to load. It is decoration: always write the name beside it.
Source: https://epidermis.getarbol.com/components/data/avatar/ · Markdown: https://epidermis.getarbol.com/components/data/avatar/index.md
## People
```tsx
import { Avatar } from '@arbol-ai/ui';
const PICTURE =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230d8273'/%3E%3Ccircle cx='16' cy='13' r='6' fill='%23f2faf8'/%3E%3Cpath d='M5 32c1-7 6-11 11-11s10 4 11 11z' fill='%23f2faf8'/%3E%3C/svg%3E";
Dra. Gloria Londoño
Andrés Felipe Rojas
Marta Ruiz (her picture failed to load)
```
## Sizes
```tsx
import { Avatar } from '@arbol-ai/ui';
```
## Organizations
A square avatar shows the whole logo; an icon stands in while there is none.
```tsx
import { BuildingsIcon } from '@phosphor-icons/react';
import { Avatar } from '@arbol-ai/ui';
} name="Clínica Norte" shape="square" size="lg" />
Clínica Norte
```
## Source
The examples above are [`src/components/avatar/avatar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/avatar/avatar.examples.tsx), which the library's tests render too.
# Badge
> A status pill: the state of a record in a word, in the color of what that state means. It is text, not a control. The apps' old variant names map to tones (see the component's documentation).
Source: https://epidermis.getarbol.com/components/data/badge/ · Markdown: https://epidermis.getarbol.com/components/data/badge/index.md
## Tones
```tsx
import { Badge } from '@arbol-ai/ui';
Draft
Handled by the agent
Confirmed
Waiting for reply
Delivery failed
Checked in
```
## Outline
No fill, for a quieter status beside soft ones.
```tsx
import { Badge } from '@arbol-ai/ui';
Cancelled
Expires in 3 days
Blocked
```
## With an icon
```tsx
import { CheckCircleIcon, ClockIcon, SparkleIcon, WarningIcon } from '@phosphor-icons/react';
import { Badge } from '@arbol-ai/ui';
AI
Paid
Pending
Refused
```
## Source
The examples above are [`src/components/badge/badge.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/badge/badge.examples.tsx), which the library's tests render too.
# ColumnPicker
> Shows, hides and reorders the columns of a table: a checkbox per column, a handle to drag it (or move it with the keyboard), and a reset. useColumnState keeps the state as plain ids the app can save, and gives DataTable its visibility and order.
Source: https://epidermis.getarbol.com/components/data/column-picker/ · Markdown: https://epidermis.getarbol.com/components/data/column-picker/index.md
## With a data table
The name cannot be hidden; the email column starts hidden.
```tsx
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnPicker, DataTable, useColumnState } from '@arbol-ai/ui';
import type { ColumnPickerColumn, DataTableFeatures } from '@arbol-ai/ui';
interface Agent {
id: string;
name: string;
channel: string;
email: string;
conversations: number;
}
const AGENTS: Agent[] = [
{ id: 'a1', name: 'Front desk', channel: 'Phone', email: 'desk@example.com', conversations: 412 },
{ id: 'a2', name: 'Reminders', channel: 'WhatsApp', email: 'reminders@example.com', conversations: 1280 },
{ id: 'a3', name: 'After hours', channel: 'Web chat', email: 'night@example.com', conversations: 96 },
];
const PICKER_COLUMNS: ColumnPickerColumn[] = [
{ id: 'name', label: 'Name', canHide: false },
{ id: 'channel', label: 'Channel' },
{ id: 'email', label: 'Email', defaultVisible: false },
{ id: 'conversations', label: 'Conversations' },
];
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', header: 'Channel', size: 130 },
{ accessorKey: 'email', header: 'Email', size: 200 },
{ accessorKey: 'conversations', header: 'Conversations', size: 150, meta: { align: 'end' } },
];
const PickerExample = () => {
const { columnVisibility, onOrderChange, onReset, onVisibleChange, state } = useColumnState({
columns: PICKER_COLUMNS,
});
return (
agent.id}
toolbar={
}
/>
);
};
```
## Source
The examples above are [`src/components/column-picker/column-picker.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/column-picker/column-picker.examples.tsx), which the library's tests render too.
# CountBadge
> A number in a small pill: the total beside a title, the filters in use, the records waiting in a queue. Use a Badge for a status in words.
Source: https://epidermis.getarbol.com/components/data/count-badge/ · Markdown: https://epidermis.getarbol.com/components/data/count-badge/index.md
## Beside a title
```tsx
import { CountBadge } from '@arbol-ai/ui';
Services
```
## Tones
The filters in use take the brand tone; records waiting for someone, the warning tone.
```tsx
import { FunnelSimpleIcon } from '@phosphor-icons/react';
import { Button, CountBadge } from '@arbol-ai/ui';
Filters
Confirm tomorrow’s appointments
```
## At an API’s cap
A total the API stopped counting at reads as at least that many; the label says what it counts.
```tsx
import { CountBadge } from '@arbol-ai/ui';
Conversations
```
## Source
The examples above are [`src/components/count-badge/count-badge.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/count-badge/count-badge.examples.tsx), which the library's tests render too.
# DataTable
> A list driven by TanStack Table v9 and drawn by Table: sortable headers, column sizes, visibility and order, rows that open their record, an empty state, and a dimmed busy state while the next answer loads. Server-sorted lists pass manualSorting.
Source: https://epidermis.getarbol.com/components/data/data-table/ · Markdown: https://epidermis.getarbol.com/components/data/data-table/index.md
## Sorted in the browser
Text columns start A to Z, figures highest first; each press flips the order.
```tsx
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Contact {
id: string;
name: string;
channel: string;
conversations: number;
}
const CONTACTS: Contact[] = [
{ id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
{ id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
{ id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
{
accessorKey: 'conversations',
header: 'Conversations',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
`#/contacts/${contact.id}`}
getRowId={(contact) => contact.id}
/>
```
## Sorted by the server
With manualSorting the rows stay in the order they came in; while the next page loads they dim.
```tsx
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Contact {
id: string;
name: string;
channel: string;
conversations: number;
}
const CONTACTS: Contact[] = [
{ id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
{ id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
{ id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
{
accessorKey: 'conversations',
header: 'Conversations',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
const ServerSortedExample = () => {
const [sorting, setSorting] = useState([{ id: 'conversations', desc: true }]);
const [isUpdating, setIsUpdating] = useState(false);
return (
contact.id}
isUpdating={isUpdating}
layout="fixed"
manualSorting
onSortingChange={(next) => {
setSorting(next);
setIsUpdating(true);
setTimeout(() => {
setIsUpdating(false);
}, 1200);
}}
sorting={sorting}
/>
);
};
```
## Every state of a list
Inside its frame and under its header: placeholder rows on the first load, a failed load with a retry, nothing yet, and nothing that matches the filters.
```tsx
import { UsersIcon } from '@phosphor-icons/react';
import type { ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
import { Button, DataTable, EmptyState, SegmentedControl } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Contact {
id: string;
name: string;
channel: string;
conversations: number;
}
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
{
accessorKey: 'conversations',
header: 'Conversations',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
type ListState = 'loading' | 'error' | 'empty' | 'filtered';
const NO_CONTACTS: Contact[] = [];
const StatesExample = () => {
const [state, setState] = useState('loading');
return (
Add contact}
description="Import a CSV or add your first contact by hand."
icon={ }
title="No contacts yet"
/>
}
errorDescription="Check your connection and try again."
isError={state === 'error'}
isFiltered={state === 'filtered'}
isLoading={state === 'loading'}
layout="fixed"
onClearFilters={() => {
setState('empty');
}}
onRetry={() => {
setState('loading');
}}
/>
);
};
```
## Columns that leave a narrow table
Each column with minTableWidth leaves when the table is narrower, header and cells, and comes back when there is room. Narrow the page to see the channel go first.
```tsx
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Contact {
id: string;
name: string;
channel: string;
conversations: number;
}
const CONTACTS: Contact[] = [
{ id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
{ id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
{ id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];
const RESPONSIVE_COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{
accessorKey: 'channel',
enableSorting: false,
header: 'Channel',
size: 140,
meta: { minTableWidth: 720 },
},
{
accessorKey: 'conversations',
header: 'Conversations',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums', minTableWidth: 480 },
},
];
contact.id}
layout="fixed"
/>
```
## Hidden columns and an empty state
```tsx
import type { ColumnDef } from '@tanstack/react-table';
import { Button, DataTable, EmptyState } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
interface Contact {
id: string;
name: string;
channel: string;
conversations: number;
}
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
{
accessorKey: 'conversations',
header: 'Conversations',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
Clear filters} variant="filtered" />
}
/>
```
## Source
The examples above are [`src/components/data-table/data-table.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/data-table/data-table.examples.tsx), which the library's tests render too.
# FilterSelect
> One filter of a list: a button that says what it filters and what is applied, in the brand tint once it narrows the list, over a list of values with “All” first, counts and icons. Single or multiple selection; a search from eight options.
Source: https://epidermis.getarbol.com/components/data/filter-select/ · Markdown: https://epidermis.getarbol.com/components/data/filter-select/index.md
## One value
```tsx
import { useState } from 'react';
import { FilterSelect } from '@arbol-ai/ui';
const SingleExample = () => {
const [status, setStatus] = useState([]);
return (
);
};
```
## Several values with counts
```tsx
import { useState } from 'react';
import { FilterSelect } from '@arbol-ai/ui';
import type { FilterSelectOption } from '@arbol-ai/ui';
const RESULTS: FilterSelectOption[] = [
{
value: 'resolved',
label: 'Resolved',
count: 812,
icon: ,
},
{
value: 'handoff',
label: 'Handed off',
count: 126,
icon: ,
},
{
value: 'missed',
label: 'Missed',
count: 64,
icon: ,
},
];
const MultipleExample = () => {
const [results, setResults] = useState(['missed']);
return (
);
};
```
## Records
Agents, sites or services: each with a face and a second line that tells two apart, which the search reads too.
```tsx
import { useState } from 'react';
import { Avatar, FilterSelect } from '@arbol-ai/ui';
import type { FilterSelectOption } from '@arbol-ai/ui';
const AGENTS: FilterSelectOption[] = [
['Front desk', 'Answers every call'],
['Reminders', 'Confirms appointments'],
['Collections', 'Calls about payments'],
['Front desk', 'North clinic, WhatsApp'],
['Surveys', 'Asks how the visit went'],
['Waiting list', 'Offers freed slots'],
['Results', 'Sends lab results'],
['After hours', 'Answers at night'],
].map(([label = '', description = ''], index) => ({
value: `agt_${index}`,
label,
description,
icon: ,
}));
const RecordsExample = () => {
const [agents, setAgents] = useState([]);
return (
);
};
```
## Records still loading
The list says so, and the button never shows the id of a value it has not read yet.
```tsx
import { FilterSelect } from '@arbol-ai/ui';
undefined} options={[]} value={['site_01']} />
```
## Source
The examples above are [`src/components/filter-select/filter-select.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/filter-select/filter-select.examples.tsx), which the library's tests render too.
# FoldingFilters
> A list’s filters folded behind one “Filters · n” button on a phone; from the sm breakpoint they sit in the filter row. Narrow the window to see it fold.
Source: https://epidermis.getarbol.com/components/data/folding-filters/ · Markdown: https://epidermis.getarbol.com/components/data/folding-filters/index.md
## A contacts filter row
```tsx
import { useState } from 'react';
import { FilterSelect, FoldingFilters } from '@arbol-ai/ui';
const FoldingExample = () => {
const [channels, setChannels] = useState([]);
const [agents, setAgents] = useState(['front-desk']);
return (
0 ? 1 : 0) + (agents.length > 0 ? 1 : 0)}>
);
};
```
## Source
The examples above are [`src/components/folding-filters/folding-filters.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/folding-filters/folding-filters.examples.tsx), which the library's tests render too.
# Kbd
> A key or a keyboard shortcut drawn as a key cap. It only shows the shortcut; the app handles the keys. Inside a menu item it is announced as the item’s shortcut.
Source: https://epidermis.getarbol.com/components/data/kbd/ · Markdown: https://epidermis.getarbol.com/components/data/kbd/index.md
## In a sentence
```tsx
import { Kbd } from '@arbol-ai/ui';
Press J and K to move between conversations, and Esc to close the
panel.
```
## A combination
KbdGroup draws each key of a combination separately.
```tsx
import { Kbd, KbdGroup } from '@arbol-ai/ui';
Send the reply
Ctrl
Enter
```
## Source
The examples above are [`src/components/kbd/kbd.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/kbd/kbd.examples.tsx), which the library's tests render too.
# TanStack Query
> The query entry: QueryView draws a query in the dashboard’s states, a skeleton or spinner before the first answer, an error with a retry, an empty state, and the data, dimmed while the next page loads. Each example has its own QueryClient and a pretend server.
Source: https://epidermis.getarbol.com/components/data/query-view/ · Markdown: https://epidermis.getarbol.com/components/data/query-view/index.md
## A table, page by page
The next page keeps the current rows on screen, dimmed, until it arrives.
```tsx
import { QueryClient, QueryClientProvider, keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataTable, Pagination, TableSkeleton } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';
import { QueryView } from '@arbol-ai/ui/query';
interface Contact {
id: string;
name: string;
channel: string;
}
const CONTACTS: Contact[] = [
{ id: 'c1', name: 'Ana Torres', channel: 'Phone' },
{ id: 'c2', name: 'Luis Gómez', channel: 'WhatsApp' },
{ id: 'c3', name: 'Marta Ruiz', channel: 'Web chat' },
{ id: 'c4', name: 'Jorge Peña', channel: 'WhatsApp' },
{ id: 'c5', name: 'Sofía Díaz', channel: 'Phone' },
{ id: 'c6', name: 'Pedro Lara', channel: 'Web chat' },
];
const PAGE_SIZE = 3;
const COLUMNS: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'channel', header: 'Channel', size: 160 },
];
const answerLater = async (answer: () => T) => {
// oxlint-disable-next-line promise/avoid-new -- a timer is the pretend server's latency
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
return answer();
};
const ExampleClient = ({ children }: { children: ReactNode }) => {
const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }), []);
return {children} ;
};
const ContactsTable = () => {
const [page, setPage] = useState(1);
const contacts = useQuery({
placeholderData: keepPreviousData,
queryFn: async () => await answerLater(() => CONTACTS.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)),
queryKey: ['contacts', page],
});
return (
}
query={contacts}
>
{(rows, { isUpdating }) => (
)}
);
};
```
## An error with a retry
The first request fails; the retry stays pending until the second one answers.
```tsx
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import type { ReactNode } from 'react';
import { QueryView } from '@arbol-ai/ui/query';
const answerLater = async (answer: () => T) => {
// oxlint-disable-next-line promise/avoid-new -- a timer is the pretend server's latency
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
return answer();
};
const ExampleClient = ({ children }: { children: ReactNode }) => {
const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }), []);
return {children} ;
};
const FlakyCampaigns = () => {
const requests = useRef(0);
const campaigns = useQuery({
queryFn: async () =>
await answerLater(() => {
requests.current += 1;
if (requests.current === 1) {
throw new Error('Service unavailable');
}
return ['Flu shots reminder', 'Missed appointments'];
}),
queryKey: ['campaigns'],
});
return (
{(names) => (
{names.map((name) => (
{name}
))}
)}
);
};
```
## An empty answer
```tsx
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { EmptyState } from '@arbol-ai/ui';
import { QueryView } from '@arbol-ai/ui/query';
const answerLater = async (answer: () => T) => {
// oxlint-disable-next-line promise/avoid-new -- a timer is the pretend server's latency
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
return answer();
};
const ExampleClient = ({ children }: { children: ReactNode }) => {
const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }), []);
return {children} ;
};
const NoAppointments = () => {
const appointments = useQuery({
queryFn: async () => await answerLater((): string[] => []),
queryKey: ['appointments'],
});
return (
}
query={appointments}
>
{(names) => (
{names.map((name) => (
{name}
))}
)}
);
};
```
## Source
The examples above are [`src/query/query-view.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/query/query-view.examples.tsx), which the library's tests render too.
# Stat
> Dashboard figures: Stat shows one number formatted for the locale with its change against the previous period (TrendDelta), a line of context, its definition behind an ⓘ and what it leads to; StatGrid lays them out by its own width; SummaryTile is a figure that also filters the list below it.
Source: https://epidermis.getarbol.com/components/data/stat/ · Markdown: https://epidermis.getarbol.com/components/data/stat/index.md
## Headline figures
Green is good news, amber bad news; a neutral metric stays gray. The change is read in words.
```tsx
import { Stat, StatGrid, TrendDelta } from '@arbol-ai/ui';
} value={3240} />
}
value={0.94}
/>
}
value={41}
/>
}
unit="min"
value="3:45"
/>
```
## Live figures, definitions and what they lead to
A live dot before the figure, the definition one press away on the ⓘ, and the work the number counts underneath.
```tsx
import { Link, Stat, StatGrid, StatusDot, TrendDelta } from '@arbol-ai/ui';
}
value={18}
/>
}
value={0.81}
/>
}
value={23}
>
Review them
```
## Loading and no previous period
```tsx
import { Stat, StatGrid, TrendDelta } from '@arbol-ai/ui';
} value={12} />
```
## Summary tiles that filter, from data
Pressing a tile shows only what it counts; pressing it again shows everything. The dots match the chart beside the strip, and a hint says what each tile counts.
```tsx
import { useState } from 'react';
import { SummaryStrip } from '@arbol-ai/ui';
import type { SummaryStripItem } from '@arbol-ai/ui';
const RESULTS: SummaryStripItem[] = [
{
id: 'resolved',
label: 'Resolved',
value: 812,
share: 0.81,
dot: 'bg-chart-1',
hint: 'Conversations the agent resolved without help.',
},
{
id: 'handoff',
label: 'Handed off',
value: 126,
share: 0.13,
dot: 'bg-amber-9',
hint: 'Conversations the agent passed to your team.',
},
{
id: 'missed',
label: 'Missed',
value: 64,
share: 0.06,
dot: 'bg-red-9',
hint: 'Calls and chats nobody answered.',
},
];
const SummaryExample = () => {
const [result, setResult] = useState(null);
return (
);
};
```
## Source
The examples above are [`src/components/stat/stat.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/stat/stat.examples.tsx), which the library's tests render too.
# StatusDot
> A small colored dot that says a status at a glance, optionally pulsing while something is live. Give it a label, visible or for screen readers only, so color is never the only sign.
Source: https://epidermis.getarbol.com/components/data/status-dot/ · Markdown: https://epidermis.getarbol.com/components/data/status-dot/index.md
## With a label
```tsx
import { StatusDot } from '@arbol-ai/ui';
```
## Label for screen readers only
When the dot stands alone, its label is still read.
```tsx
import { StatusDot } from '@arbol-ai/ui';
Mónica, front desk agent
```
## Source
The examples above are [`src/components/status-dot/status-dot.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/status-dot/status-dot.examples.tsx), which the library's tests render too.
# Table
> Records in rows and columns, in a rounded frame. Arrow keys move through it, rows open with a click, Enter or a tap, columns sort, and rows can be selected. Use DataTable when TanStack Table drives the list.
Source: https://epidermis.getarbol.com/components/data/table/ · Markdown: https://epidermis.getarbol.com/components/data/table/index.md
## Basic
```tsx
import { Cell, Column, Row, Table, TableBody, TableHeader } from '@arbol-ai/ui';
const APPOINTMENTS = [
{
id: 'a1',
patient: 'Ana Torres',
document: 'CC 1.020.304.050',
service: 'General check-up',
time: '09:30',
minutes: 30,
},
{
id: 'a2',
patient: 'Luis Gómez',
document: 'CC 80.111.222',
service: 'Dental cleaning',
time: '10:15',
minutes: 45,
},
{
id: 'a3',
patient: 'Marta Ruiz',
document: 'TI 1.001.234.567',
service: 'Vaccination',
time: '11:00',
minutes: 15,
},
];
Patient
Service
Time
{APPOINTMENTS.map((appointment) => (
| {appointment.patient} |
{appointment.service} |
{appointment.time} |
))}
```
## Rows that open their record
A press anywhere on a row opens the patient; the name is a real link (new tab, copy link), and the “⋯” menu holds the rest. Arrow keys reach the link and the menu.
```tsx
import { Avatar, Badge, Cell, Column, Row, RowMenu, RowTitle, Table, TableBody, TableHeader } from '@arbol-ai/ui';
const APPOINTMENTS = [
{
id: 'a1',
patient: 'Ana Torres',
document: 'CC 1.020.304.050',
service: 'General check-up',
time: '09:30',
minutes: 30,
},
{
id: 'a2',
patient: 'Luis Gómez',
document: 'CC 80.111.222',
service: 'Dental cleaning',
time: '10:15',
minutes: 45,
},
{
id: 'a3',
patient: 'Marta Ruiz',
document: 'TI 1.001.234.567',
service: 'Vaccination',
time: '11:00',
minutes: 15,
},
];
Patient
Service
Time
Actions
{APPOINTMENTS.map((appointment) => (
30 ? Long visit : undefined}
description={appointment.document}
href={`#/patients/${appointment.id}`}
leading={ }
>
{appointment.patient}
|
{appointment.service} |
{appointment.time} |
undefined },
]}
label={`Actions for ${appointment.patient}`}
/>
|
))}
```
## While the rows load
Placeholder rows hold the table’s place under its real header, across every column.
```tsx
import { Column, Table, TableBody, TableHeader } from '@arbol-ai/ui';
Patient
Service
Time
{[]}
```
## Sorting and row actions
Press a header to sort by it; press a row (or Enter on a focused one) to open it.
```tsx
import { useState } from 'react';
import type { SortDescriptor } from 'react-aria-components';
import { Cell, Column, Row, Table, TableBody, TableHeader } from '@arbol-ai/ui';
const APPOINTMENTS = [
{
id: 'a1',
patient: 'Ana Torres',
document: 'CC 1.020.304.050',
service: 'General check-up',
time: '09:30',
minutes: 30,
},
{
id: 'a2',
patient: 'Luis Gómez',
document: 'CC 80.111.222',
service: 'Dental cleaning',
time: '10:15',
minutes: 45,
},
{
id: 'a3',
patient: 'Marta Ruiz',
document: 'TI 1.001.234.567',
service: 'Vaccination',
time: '11:00',
minutes: 15,
},
];
const SortableExample = () => {
const [sort, setSort] = useState({ column: 'patient', direction: 'ascending' });
const [opened, setOpened] = useState('');
const rows = APPOINTMENTS.toSorted((a, b) => {
const order = sort.column === 'minutes' ? a.minutes - b.minutes : a.patient.localeCompare(b.patient);
return sort.direction === 'ascending' ? order : -order;
});
return (
{
setOpened(String(id));
}}
onSortChange={setSort}
sortDescriptor={sort}
>
Patient
Service
Minutes
{(appointment) => (
| {appointment.patient} |
{appointment.service} |
{appointment.minutes} |
)}
Opened: {opened === '' ? '—' : opened}
);
};
```
## Selection
selectionMode adds a checkbox per row and, for multiple, a select-all box.
```tsx
import { useState } from 'react';
import type { Selection } from 'react-aria-components';
import { Cell, Column, Row, Table, TableBody, TableHeader } from '@arbol-ai/ui';
const APPOINTMENTS = [
{
id: 'a1',
patient: 'Ana Torres',
document: 'CC 1.020.304.050',
service: 'General check-up',
time: '09:30',
minutes: 30,
},
{
id: 'a2',
patient: 'Luis Gómez',
document: 'CC 80.111.222',
service: 'Dental cleaning',
time: '10:15',
minutes: 45,
},
{
id: 'a3',
patient: 'Marta Ruiz',
document: 'TI 1.001.234.567',
service: 'Vaccination',
time: '11:00',
minutes: 15,
},
];
const SelectionExample = () => {
const [selected, setSelected] = useState(new Set(['a2']));
const count = selected === 'all' ? APPOINTMENTS.length : selected.size;
return (
Patient
Service
{(appointment) => (
| {appointment.patient} |
{appointment.service} |
)}
{count} selected
);
};
```
## Empty
```tsx
import { Column, EmptyState, Table, TableBody, TableHeader } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/table/table.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/table/table.examples.tsx), which the library's tests render too.
# Feedback
> The feedback components of @arbol-ai/ui: EmptyState, Notice, ProgressBar, Skeleton, Spinner, State screens, TableSkeleton, Toast.
Source: https://epidermis.getarbol.com/components/feedback/ · Markdown: https://epidermis.getarbol.com/components/feedback/index.md
- [EmptyState](https://epidermis.getarbol.com/components/feedback/empty-state/): What a list, table or panel shows with nothing to show. The default variant invites the first record; filtered says the filters hide every record, so the list reads as narrowed rather than empty; error says the records did not load and offers to try again.
- [Notice](https://epidermis.getarbol.com/components/feedback/notice/): An inline message beside the content it is about: saved, failed, heads-up, or a quiet neutral aside. Errors that just happened are read out at once; everything else is announced politely.
- [ProgressBar](https://epidermis.getarbol.com/components/feedback/progress-bar/): How far a task has gone: a bar that fills, with its label and value, or a moving bar while the share is unknown. Use UploadProgress for files on their way.
- [Skeleton](https://epidermis.getarbol.com/components/feedback/skeleton/): A placeholder in the shape of content that is still loading. It pulses gently and holds still for people who reduce motion. Mark the loading region with aria-busy.
- [Spinner](https://epidermis.getarbol.com/components/feedback/spinner/): A spinning circle for a wait of unknown length, announced to screen readers as "Loading…" or its label. It turns only for people who allow motion.
- [State screens](https://epidermis.getarbol.com/components/feedback/state-screen/): What a page or a region shows instead of its content: ErrorState when it failed, LoadingState while it loads, and FullPageMessage for a whole screen before the app’s frame exists.
- [TableSkeleton](https://epidermis.getarbol.com/components/feedback/table-skeleton/): The first paint of a table whose rows are loading: the same frame, header, row height and column shapes, announced as loading. Once rows exist, keep them and dim them with DataTable’s isUpdating instead.
- [Toast](https://epidermis.getarbol.com/components/feedback/toast/): A short message about something that just happened, called from anywhere with toast.success, toast.error, toast.info or toast.warning. Render one Toaster in the app.
# EmptyState
> What a list, table or panel shows with nothing to show. The default variant invites the first record; filtered says the filters hide every record, so the list reads as narrowed rather than empty; error says the records did not load and offers to try again.
Source: https://epidermis.getarbol.com/components/feedback/empty-state/ · Markdown: https://epidermis.getarbol.com/components/feedback/empty-state/index.md
## Nothing here yet
```tsx
import { UsersIcon } from '@phosphor-icons/react';
import { Button, EmptyState } from '@arbol-ai/ui';
Add contact}
description="Import a CSV or add your first contact by hand."
icon={ }
title="No contacts yet"
/>
```
## No results for these filters
The title and the “Clear filters” button come from the library in the active language; the app passes what clearing does.
```tsx
import { EmptyState } from '@arbol-ai/ui';
undefined} variant="filtered" />
```
## A load that failed
Read out when it appears; “Try again” waits while the retry runs.
```tsx
import { EmptyState } from '@arbol-ai/ui';
undefined}
variant="error"
/>
```
## Without actions
```tsx
import { MegaphoneIcon } from '@phosphor-icons/react';
import { EmptyState } from '@arbol-ai/ui';
}
title="No campaigns scheduled"
/>
```
## Source
The examples above are [`src/components/empty-state/empty-state.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/empty-state/empty-state.examples.tsx), which the library's tests render too.
# Notice
> An inline message beside the content it is about: saved, failed, heads-up, or a quiet neutral aside. Errors that just happened are read out at once; everything else is announced politely.
Source: https://epidermis.getarbol.com/components/feedback/notice/ · Markdown: https://epidermis.getarbol.com/components/feedback/notice/index.md
## Tones
```tsx
import { InfoIcon, WarningIcon } from '@phosphor-icons/react';
import { Notice } from '@arbol-ai/ui';
This agent has no tools from this system yet.
}>Only administrators change these settings.
Template saved. Agents use it from the next call.
} title="Two agents share this number" tone="warning">
Calls go to whichever answers first.
This WhatsApp number is disconnected.
```
## Action and dismiss
```tsx
import { useState } from 'react';
import { Button, Notice } from '@arbol-ai/ui';
const DismissExample = () => {
const [isShown, setIsShown] = useState(true);
if (!isShown) {
return (
{
setIsShown(true);
}}
variant="outline"
>
Show the notice again
);
}
return (
Reconnect
}
className="max-w-xl"
onDismiss={() => {
setIsShown(false);
}}
alert={false}
tone="error"
>
We couldn’t sync the clinic’s calendar since 9:40.
);
};
```
## Source
The examples above are [`src/components/notice/notice.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/notice/notice.examples.tsx), which the library's tests render too.
# ProgressBar
> How far a task has gone: a bar that fills, with its label and value, or a moving bar while the share is unknown. Use UploadProgress for files on their way.
Source: https://epidermis.getarbol.com/components/feedback/progress-bar/ · Markdown: https://epidermis.getarbol.com/components/feedback/progress-bar/index.md
## Beside the numbers it sums up
Without a visible label, the bar takes an aria-label; the text beside it says the same.
```tsx
import { ProgressBar } from '@arbol-ai/ui';
```
## With a label
The value shows as a percentage, or in the app’s words with valueLabel.
```tsx
import { ProgressBar } from '@arbol-ai/ui';
```
## Unknown share
```tsx
import { ProgressBar } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/progress-bar/progress-bar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/progress-bar/progress-bar.examples.tsx), which the library's tests render too.
# Skeleton
> A placeholder in the shape of content that is still loading. It pulses gently and holds still for people who reduce motion. Mark the loading region with aria-busy.
Source: https://epidermis.getarbol.com/components/feedback/skeleton/ · Markdown: https://epidermis.getarbol.com/components/feedback/skeleton/index.md
## A list row
```tsx
import { Skeleton } from '@arbol-ai/ui';
```
## A card
```tsx
import { Skeleton } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/skeleton/skeleton.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/skeleton/skeleton.examples.tsx), which the library's tests render too.
# Spinner
> A spinning circle for a wait of unknown length, announced to screen readers as "Loading…" or its label. It turns only for people who allow motion.
Source: https://epidermis.getarbol.com/components/feedback/spinner/ · Markdown: https://epidermis.getarbol.com/components/feedback/spinner/index.md
## Inline
```tsx
import { Spinner } from '@arbol-ai/ui';
Refreshing the contact list
```
## Sizes
```tsx
import { Spinner } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/spinner/spinner.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/spinner/spinner.examples.tsx), which the library's tests render too.
# State screens
> What a page or a region shows instead of its content: ErrorState when it failed, LoadingState while it loads, and FullPageMessage for a whole screen before the app’s frame exists.
Source: https://epidermis.getarbol.com/components/feedback/state-screen/ · Markdown: https://epidermis.getarbol.com/components/feedback/state-screen/index.md
## Error with a retry
The retry keeps the focus while it runs.
```tsx
import { useState } from 'react';
import { ErrorState } from '@arbol-ai/ui';
const RetryExample = () => {
const [isRetrying, setIsRetrying] = useState(false);
return (
{
setIsRetrying(true);
setTimeout(() => {
setIsRetrying(false);
}, 1500);
}}
/>
);
};
```
## Access error
```tsx
import { LockSimpleIcon } from '@phosphor-icons/react';
import { ButtonLink, ErrorState } from '@arbol-ai/ui';
Back to the dashboard
}
description="This page is for administrators. Ask one to give you that role."
icon={ }
title="Your role doesn’t open this page"
/>
```
## Loading
```tsx
import { LoadingState } from '@arbol-ai/ui';
```
## Full-page message
Its own main landmark and h1, for screens outside AppShell.
```tsx
import { Button, FullPageMessage } from '@arbol-ai/ui';
Sign in again}
className="min-h-96"
description="You can sign in again whenever you like."
title="You signed out"
/>
```
## Source
The examples above are [`src/components/state-screen/state-screen.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/state-screen/state-screen.examples.tsx), which the library's tests render too.
# TableSkeleton
> The first paint of a table whose rows are loading: the same frame, header, row height and column shapes, announced as loading. Once rows exist, keep them and dim them with DataTable’s isUpdating instead.
Source: https://epidermis.getarbol.com/components/feedback/table-skeleton/ · Markdown: https://epidermis.getarbol.com/components/feedback/table-skeleton/index.md
## A contacts list
```tsx
import { TableSkeleton } from '@arbol-ai/ui';
```
## Standard rows
```tsx
import { TableSkeleton } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/table-skeleton/table-skeleton.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/table-skeleton/table-skeleton.examples.tsx), which the library's tests render too.
# Toast
> A short message about something that just happened, called from anywhere with toast.success, toast.error, toast.info or toast.warning. Render one Toaster in the app.
Source: https://epidermis.getarbol.com/components/feedback/toast/ · Markdown: https://epidermis.getarbol.com/components/feedback/toast/index.md
## Statuses
Hovering or focusing the toasts pauses their time; one with an action stays until closed.
```tsx
import { Button, Toaster, toast } from '@arbol-ai/ui';
{
toast.success('Contact saved');
}}
variant="outline"
>
Success
{
toast.error('Could not send the campaign', {
description: 'The WhatsApp number is disconnected.',
});
}}
variant="outline"
>
Error
{
toast.info('Export started', { description: 'We will email you the file.' });
}}
variant="outline"
>
Info
{
toast.warning('Contact deleted', {
action: {
label: 'Undo',
onAction: () => {
toast.success('Contact restored');
},
},
});
}}
variant="outline"
>
With an action
```
## Source
The examples above are [`src/components/toast/toast.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/toast/toast.examples.tsx), which the library's tests render too.
# Forms
> The forms components of @arbol-ai/ui: Checkbox, Combo box, Field, Form, Form save bar, Number field, Radio group, Search field, Segmented control, Select, Slider, Switch, TanStack Form, Text field.
Source: https://epidermis.getarbol.com/components/forms/ · Markdown: https://epidermis.getarbol.com/components/forms/index.md
- [Checkbox](https://epidermis.getarbol.com/components/forms/checkbox/): A yes-or-no choice saved with a form, and CheckboxGroup for several options that make one list. Use Switch for settings that apply at once.
- [Combo box](https://epidermis.getarbol.com/components/forms/combo-box/): A choice from a long list, found by typing. It filters as the person types and says when nothing matches; the value is the chosen option id.
- [Field](https://epidermis.getarbol.com/components/forms/field/): The parts every field is made of: Label, Description, FieldError and FieldHelp (the description, replaced by the error while invalid). Field wraps a control that is not a React Aria field with them, and nativeFieldBox styles a native select or text area like the rest; FieldGroup holds an input and the buttons that act on it in one box.
- [Form](https://epidermis.getarbol.com/components/forms/form/): Stacks fields with even spacing and leaves validation to the app: fields show its messages, never the browser bubbles. validationErrors shows a server answer under each field; FormError says why the whole form was not saved.
- [Form save bar](https://epidermis.getarbol.com/components/forms/form-save-bar/): The SaveBar of a TanStack form (from the form entry): it shows while the values differ from the saved ones, is pending while the form saves, says why a save was refused, and Discard resets the form.
- [Number field](https://epidermis.getarbol.com/components/forms/number-field/): A number typed or stepped, formatted and parsed in the locale. Use formatOptions for currency, percentages and units.
- [Radio group](https://epidermis.getarbol.com/components/forms/radio-group/): One choice among a few options, all in view. Radio for short options, RadioCard for options that need a sentence of explanation.
- [Search field](https://epidermis.getarbol.com/components/forms/search-field/): A search box with a clear button; Escape clears it. debounceMs waits for a pause in typing before searching, and clearing searches at once.
- [Segmented control](https://epidermis.getarbol.com/components/forms/segmented-control/): One of 2–4 mutually exclusive options side by side, as a radio group: arrow keys move the choice and the thumb slides to it. Use Tabs to switch what a page shows.
- [Select](https://epidermis.getarbol.com/components/forms/select/): A choice from a list that opens from a trigger. The value is the chosen option id; selectionMode="multiple" takes several. Use ComboBox when the list is long enough to search.
- [Slider](https://epidermis.getarbol.com/components/forms/slider/): A value picked by dragging along a track, with its formatted value beside the label. Two values make a range whose thumbs never cross.
- [Switch](https://epidermis.getarbol.com/components/forms/switch/): An on/off setting that applies at once, with an optional label and description. Use Checkbox for a choice saved with a form.
- [TanStack Form](https://epidermis.getarbol.com/components/forms/app-form/): The fields bound to TanStack Form (from the form entry): declare the form once with useAppForm, and each field is one line that shows its value and its first error once touched. SubmitButton is pending while the form saves; FormError says why a submission was refused.
- [Text field](https://epidermis.getarbol.com/components/forms/text-field/): A labelled text box with its help and error message, in one line. Use multiline for notes; Input and TextArea are the boxes alone, for compositions of your own.
# TanStack Form
> The fields bound to TanStack Form (from the form entry): declare the form once with useAppForm, and each field is one line that shows its value and its first error once touched. SubmitButton is pending while the form saves; FormError says why a submission was refused.
Source: https://epidermis.getarbol.com/components/forms/app-form/ · Markdown: https://epidermis.getarbol.com/components/forms/app-form/index.md
## Invite a teammate
Submit it empty to see every error, or with an email on example.org to see a refusal.
```tsx
import { useState } from 'react';
import type { Key } from 'react-aria-components';
import { SelectItem } from '@arbol-ai/ui';
import { useAppForm } from '@arbol-ai/ui/form';
interface Invitation {
email: string;
role: Key | null;
notify: boolean;
}
const NO_INVITATION: Invitation = { email: '', role: null, notify: true };
const InviteForm = () => {
const [sentTo, setSentTo] = useState('');
const form = useAppForm({
defaultValues: NO_INVITATION,
onSubmit: ({ value }) => {
setSentTo(value.email);
},
validators: {
onSubmit: ({ value }) =>
value.email.endsWith('@example.org') ? 'Invitations to example.org are turned off.' : undefined,
},
});
return (
/^\S+@\S+\.\S+$/u.test(value) ? undefined : 'Enter an email like ana@example.com.',
}}
>
{(field) => (
)}
(value === null ? 'Choose a role.' : undefined) }}
>
{(field) => (
Front desk
Doctor
Administrator
)}
{(field) => (
Notify about new conversations
)}
Send invitation
{sentTo === '' ? null : `Invitation sent to ${sentTo}.`}
);
};
```
## Source
The examples above are [`src/form/app-form.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/form/app-form.examples.tsx), which the library's tests render too.
# Checkbox
> A yes-or-no choice saved with a form, and CheckboxGroup for several options that make one list. Use Switch for settings that apply at once.
Source: https://epidermis.getarbol.com/components/forms/checkbox/ · Markdown: https://epidermis.getarbol.com/components/forms/checkbox/index.md
## With a description
```tsx
import { Checkbox } from '@arbol-ai/ui';
Send reminders by WhatsApp
```
## Group
```tsx
import { Checkbox, CheckboxGroup } from '@arbol-ai/ui';
WhatsApp
Phone call
Email
```
## Select all
An indeterminate box says that only some are selected.
```tsx
import { useState } from 'react';
import { Checkbox, CheckboxGroup } from '@arbol-ai/ui';
const CONTACTS = ['Ana Torres', 'Luis Gómez', 'Marta Ruiz'];
const SelectAllExample = () => {
const [selected, setSelected] = useState(['Ana Torres']);
return (
0 && selected.length < CONTACTS.length}
isSelected={selected.length === CONTACTS.length}
onChange={(isSelected) => {
setSelected(isSelected ? CONTACTS : []);
}}
>
All contacts
{CONTACTS.map((contact) => (
{contact}
))}
);
};
```
## Source
The examples above are [`src/components/checkbox/checkbox.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/checkbox/checkbox.examples.tsx), which the library's tests render too.
# Combo box
> A choice from a long list, found by typing. It filters as the person types and says when nothing matches; the value is the chosen option id.
Source: https://epidermis.getarbol.com/components/forms/combo-box/ · Markdown: https://epidermis.getarbol.com/components/forms/combo-box/index.md
## Filtered by typing
Type "an" to see the matches, or "zz" to see the empty state.
```tsx
import { ComboBox, ComboBoxItem } from '@arbol-ai/ui';
const CONTACTS = [
{ id: 'cnt_1', name: 'Ana Torres' },
{ id: 'cnt_2', name: 'Andrés Gómez' },
{ id: 'cnt_3', name: 'Beatriz Rojas' },
{ id: 'cnt_4', name: 'Carlos Pérez' },
{ id: 'cnt_5', name: 'Lucía Martínez' },
];
{(contact) => {contact.name} }
```
## Sections
```tsx
import { ComboBox, ComboBoxItem, ComboBoxSection } from '@arbol-ai/ui';
Clínica Norte
Sede Chapinero
Sede El Poblado
```
## Source
The examples above are [`src/components/combo-box/combo-box.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/combo-box/combo-box.examples.tsx), which the library's tests render too.
# Field
> The parts every field is made of: Label, Description, FieldError and FieldHelp (the description, replaced by the error while invalid). Field wraps a control that is not a React Aria field with them, and nativeFieldBox styles a native select or text area like the rest; FieldGroup holds an input and the buttons that act on it in one box.
Source: https://epidermis.getarbol.com/components/forms/field/ · Markdown: https://epidermis.getarbol.com/components/forms/field/index.md
## Plain children
The short form: an Input or TextArea inside is tied to the label, the help and the error by itself.
```tsx
import { Field, Input, TextArea } from '@arbol-ai/ui';
```
## A native select or text area
nativeFieldBox gives a native control the look of the fields beside it, from the theme tokens; the Field’s id goes on the control.
```tsx
import { Field, nativeFieldBox } from '@arbol-ai/ui';
Dr. Ruiz
Room 2
```
## A control of your own
Field gives the control the ids that tie it to its label, help and error. Here an input with suggestions validates what was typed.
```tsx
import { useId, useState } from 'react';
import { Field, Input } from '@arbol-ai/ui';
const USUAL_DURATIONS = ['15m', '30m', '45m', '1h', '1h30', '2h'];
const DurationExample = () => {
const [text, setText] = useState('30m');
const suggestions = useId();
const isValid = text === '' || /^\d+(?:m|h(?:\d+)?)$/u.test(text);
return (
{(control) => (
<>
{
setText(event.target.value);
}}
value={text}
/>
{USUAL_DURATIONS.map((duration) => (
{duration}
))}
>
)}
);
};
```
## An input with a button
FieldGroup draws one box around the input and its button, with one focus ring.
```tsx
import { EyeIcon, EyeSlashIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { TextField as AriaTextField } from 'react-aria-components';
import { Button, FieldGroup, FieldGroupInput, FieldHelp, Label } from '@arbol-ai/ui';
const PasswordExample = () => {
const [isVisible, setIsVisible] = useState(false);
const label = 'Password';
return (
{label}
{
setIsVisible((visible) => !visible);
}}
size="icon-sm"
variant="ghost"
>
{isVisible ? : }
);
};
```
## Source
The examples above are [`src/components/field/field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/field/field.examples.tsx), which the library's tests render too.
# Form save bar
> The SaveBar of a TanStack form (from the form entry): it shows while the values differ from the saved ones, is pending while the form saves, says why a save was refused, and Discard resets the form.
Source: https://epidermis.getarbol.com/components/forms/form-save-bar/ · Markdown: https://epidermis.getarbol.com/components/forms/form-save-bar/index.md
## Settings form
Change a value, then undo it: the bar steps aside. Save, and it says what the save did.
```tsx
import { useState } from 'react';
import { SettingGroup, SettingRow } from '@arbol-ai/ui';
import { FormSaveBar, useAppForm } from '@arbol-ai/ui/form';
interface Clinic {
name: string;
greeting: string;
}
const ClinicSettings = () => {
const [isSaved, setIsSaved] = useState(false);
const form = useAppForm({
defaultValues: { name: 'Clínica Norte', greeting: 'Hi, this is Clínica Norte.' } satisfies Clinic,
onSubmit: ({ value }) => {
// What was saved becomes the form's defaults, so the bar steps aside.
form.reset(value);
setIsSaved(true);
},
});
return (
{(field) => }
{(field) => }
);
};
```
## Source
The examples above are [`src/form/form-save-bar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/form/form-save-bar.examples.tsx), which the library's tests render too.
# Form
> Stacks fields with even spacing and leaves validation to the app: fields show its messages, never the browser bubbles. validationErrors shows a server answer under each field; FormError says why the whole form was not saved.
Source: https://epidermis.getarbol.com/components/forms/form/ · Markdown: https://epidermis.getarbol.com/components/forms/form/index.md
## A new contact
Save with the name empty, then with "taken@example.com" as the email.
```tsx
import { useState } from 'react';
import { Button, Checkbox, Form, FormError, Select, SelectItem, TextField } from '@arbol-ai/ui';
const NewContactExample = () => {
const [errors, setErrors] = useState>({});
const [refusal, setRefusal] = useState();
return (
{
event.preventDefault();
const data = new FormData(event.currentTarget);
const name = data.get('name');
setErrors(typeof name === 'string' && name.trim() !== '' ? {} : { name: 'Enter the contact name.' });
setRefusal(
data.get('email') === 'taken@example.com'
? 'A contact with that email already exists. Open it instead of creating another.'
: undefined
);
}}
validationErrors={errors}
>
WhatsApp
Phone call
Send appointment reminders
{refusal}
Save contact
);
};
```
## Source
The examples above are [`src/components/form/form.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/form/form.examples.tsx), which the library's tests render too.
# Number field
> A number typed or stepped, formatted and parsed in the locale. Use formatOptions for currency, percentages and units.
Source: https://epidermis.getarbol.com/components/forms/number-field/ · Markdown: https://epidermis.getarbol.com/components/forms/number-field/index.md
## Bounded steps
```tsx
import { NumberField } from '@arbol-ai/ui';
```
## Currency, percent and units
```tsx
import { NumberField } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/number-field/number-field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/number-field/number-field.examples.tsx), which the library's tests render too.
# Radio group
> One choice among a few options, all in view. Radio for short options, RadioCard for options that need a sentence of explanation.
Source: https://epidermis.getarbol.com/components/forms/radio-group/ · Markdown: https://epidermis.getarbol.com/components/forms/radio-group/index.md
## Radios
```tsx
import { Radio, RadioGroup } from '@arbol-ai/ui';
WhatsApp
SMS
Phone call
```
## Cards
The whole card selects; in a row, the cards share the width.
```tsx
import { HeadsetIcon, RobotIcon } from '@phosphor-icons/react';
import { RadioCard, RadioGroup } from '@arbol-ai/ui';
The agent
Your team
```
## Required
```tsx
import { Radio, RadioGroup } from '@arbol-ai/ui';
Email
WhatsApp
```
## Source
The examples above are [`src/components/radio-group/radio-group.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/radio-group/radio-group.examples.tsx), which the library's tests render too.
# Search field
> A search box with a clear button; Escape clears it. debounceMs waits for a pause in typing before searching, and clearing searches at once.
Source: https://epidermis.getarbol.com/components/forms/search-field/ · Markdown: https://epidermis.getarbol.com/components/forms/search-field/index.md
## In a toolbar
Without a label, the placeholder names the box.
```tsx
import { SearchField } from '@arbol-ai/ui';
```
## Debounced
The list searches 300 ms after the last keystroke.
```tsx
import { useState } from 'react';
import { SearchField } from '@arbol-ai/ui';
const DebouncedExample = () => {
const [search, setSearch] = useState('');
return (
Searching for: {search === '' ? '—' : search}
);
};
```
## Labelled
```tsx
import { SearchField } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/search-field/search-field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/search-field/search-field.examples.tsx), which the library's tests render too.
# Segmented control
> One of 2–4 mutually exclusive options side by side, as a radio group: arrow keys move the choice and the thumb slides to it. Use Tabs to switch what a page shows.
Source: https://epidermis.getarbol.com/components/forms/segmented-control/ · Markdown: https://epidermis.getarbol.com/components/forms/segmented-control/index.md
## Sizes
md for forms and settings; sm (28px) for toolbars.
```tsx
import { useState } from 'react';
import { SegmentedControl } from '@arbol-ai/ui';
const SizesExample = () => {
const [mode, setMode] = useState<'sequential' | 'parallel'>('sequential');
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
return (
);
};
```
## Disabled
```tsx
import { SegmentedControl } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/segmented-control/segmented-control.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/segmented-control/segmented-control.examples.tsx), which the library's tests render too.
# Select
> A choice from a list that opens from a trigger. The value is the chosen option id; selectionMode="multiple" takes several. Use ComboBox when the list is long enough to search.
Source: https://epidermis.getarbol.com/components/forms/select/ · Markdown: https://epidermis.getarbol.com/components/forms/select/index.md
## Labelled, with icons
```tsx
import { ChatCircleIcon, PhoneIcon, WhatsappLogoIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import type { Key } from 'react-aria-components';
import { Select, SelectItem } from '@arbol-ai/ui';
const ChannelExample = () => {
const [channel, setChannel] = useState('whatsapp');
return (
WhatsApp
Web chat
Phone call
);
};
```
## Sections
```tsx
import { Select, SelectItem, SelectSection } from '@arbol-ai/ui';
Booked
Confirmed
Attended
No-show
Cancelled
```
## Several options
```tsx
import { Select, SelectItem } from '@arbol-ai/ui';
Monday
Tuesday
Wednesday
Thursday
Friday
```
## Compact, in a toolbar
```tsx
import { Select, SelectItem } from '@arbol-ai/ui';
All agents
Front desk
Collections
```
## Source
The examples above are [`src/components/select/select.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/select/select.examples.tsx), which the library's tests render too.
# Slider
> A value picked by dragging along a track, with its formatted value beside the label. Two values make a range whose thumbs never cross.
Source: https://epidermis.getarbol.com/components/forms/slider/ · Markdown: https://epidermis.getarbol.com/components/forms/slider/index.md
## One value
```tsx
import { useState } from 'react';
import { Slider } from '@arbol-ai/ui';
const SpeedExample = () => {
const [speed, setSpeed] = useState(1);
return (
);
};
```
## A range
```tsx
import { Slider } from '@arbol-ai/ui';
```
## Without a visible label
```tsx
import { Slider } from '@arbol-ai/ui';
A
A
```
## Source
The examples above are [`src/components/slider/slider.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/slider/slider.examples.tsx), which the library's tests render too.
# Switch
> An on/off setting that applies at once, with an optional label and description. Use Checkbox for a choice saved with a form.
Source: https://epidermis.getarbol.com/components/forms/switch/ · Markdown: https://epidermis.getarbol.com/components/forms/switch/index.md
## Labelled
```tsx
import { useState } from 'react';
import { Switch } from '@arbol-ai/ui';
const AutoReplyExample = () => {
const [isSelected, setIsSelected] = useState(true);
return (
Answer automatically
);
};
```
## Without a visible label
In a table row, the switch is named with aria-label.
```tsx
import { Switch } from '@arbol-ai/ui';
October reminders
```
## Disabled
```tsx
import { Switch } from '@arbol-ai/ui';
Record calls
```
## Source
The examples above are [`src/components/switch/switch.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/switch/switch.examples.tsx), which the library's tests render too.
# Text field
> A labelled text box with its help and error message, in one line. Use multiline for notes; Input and TextArea are the boxes alone, for compositions of your own.
Source: https://epidermis.getarbol.com/components/forms/text-field/ · Markdown: https://epidermis.getarbol.com/components/forms/text-field/index.md
## Labelled and described
```tsx
import { TextField } from '@arbol-ai/ui';
```
## Validation
The error replaces the description while the value is invalid. Here the app validates as the person types.
```tsx
import { useState } from 'react';
import { TextField } from '@arbol-ai/ui';
const EmailExample = () => {
const [email, setEmail] = useState('ana@');
const isInvalid = email !== '' && !/^\S+@\S+\.\S+$/u.test(email);
return (
);
};
```
## Multiline
The text area grows with what is typed.
```tsx
import { TextField } from '@arbol-ai/ui';
```
## Input and TextArea alone
Outside a field they are native elements: name them with aria-label.
```tsx
import { Input, TextArea } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/text-field/text-field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/text-field/text-field.examples.tsx), which the library's tests render too.
# Layout
> The layout components of @arbol-ai/ui: App shell, Card, Disclosure, Page, Save bar, Section header, Separator, Settings, TanStack Router.
Source: https://epidermis.getarbol.com/components/layout/ · Markdown: https://epidermis.getarbol.com/components/layout/index.md
- [App shell](https://epidermis.getarbol.com/components/layout/app-shell/): The frame every dashboard page shares: a skip link, a sidebar that folds into an icon rail from lg and becomes a drawer on phones, a header for breadcrumbs and actions, and the page in a scrolling main landmark.
- [Card](https://epidermis.getarbol.com/components/layout/card/): A bordered surface that groups one subject: a summary, a settings group, a chart. Compose it from a header (title, description, action), content and a footer; leave out any part.
- [Disclosure](https://epidermis.getarbol.com/components/layout/disclosure/): A section that folds away, with a hint of what is inside while closed. Use it for the optional part of a form or advanced settings; stack several in a DisclosureGroup for an accordion. Leave out the title to draw your own trigger.
- [Page](https://epidermis.getarbol.com/components/layout/page/): The frame of every screen: a centered column, a header with the title, what the page is for and its actions, and the content below. Use DetailHeader for the page of one record.
- [Save bar](https://epidermis.getarbol.com/components/layout/save-bar/): The foot of a page form: Save, Discard and what is pending, floating above the bottom of the page while something is unsaved. A clean form shows no bar, only what the last save did.
- [Section header](https://epidermis.getarbol.com/components/layout/section-header/): Names a section of a page: SectionHeader for a titled section with its one action, SectionEyebrow for the quiet uppercase name of a panel.
- [Separator](https://epidermis.getarbol.com/components/layout/separator/): A hairline between two groups of content: sections of a card, or groups of buttons in a toolbar. A vertical separator stretches to the height of its row.
- [Settings](https://epidermis.getarbol.com/components/layout/settings/): Settings pages: SettingGroup frames related settings as one card, SettingRow pairs a setting’s name and explanation with its control, and SettingToggle is an on/off setting whose whole row toggles.
- [TanStack Router](https://epidermis.getarbol.com/components/layout/router/): The router entry: RouterUIProvider routes every href the components render through TanStack Router, and useUnsavedChangesBlocker asks before a page with a draft is left. Each example runs its own router in memory.
# App shell
> The frame every dashboard page shares: a skip link, a sidebar that folds into an icon rail from lg and becomes a drawer on phones, a header for breadcrumbs and actions, and the page in a scrolling main landmark.
Source: https://epidermis.getarbol.com/components/layout/app-shell/ · Markdown: https://epidermis.getarbol.com/components/layout/app-shell/index.md
## Dashboard frame
Fold the sidebar with the header’s button; on the rail, entries name themselves in a tooltip, Billing shows its pages beside its icon, and the organization and account menus show their pictures alone. The other apps open in a new tab. Below lg the menu button opens the drawer.
```tsx
import { ChatsIcon, GearSixIcon, HourglassIcon, MagnifyingGlassIcon, MegaphoneIcon, ReceiptIcon, RobotIcon, SquaresFourIcon, UserCircleIcon, UsersIcon, UsersThreeIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { AccountMenu, AppShell, Button, ColorSchemeSubmenu, LanguageSubmenu, MenuItem, MenuSeparator, OrganizationMenu, PageContainer, PageDescription, PageHeader, PageTitle, productIcon } from '@arbol-ai/ui';
import type { ColorScheme, NavigationGroup } from '@arbol-ai/ui';
const NAVIGATION: NavigationGroup[] = [
{
items: [
{ label: 'Overview', href: '#/app-shell', icon: SquaresFourIcon },
{ label: 'Conversations', href: '#/app-shell/conversations', icon: ChatsIcon },
{ label: 'Contacts', href: '#/app-shell/contacts', icon: UsersIcon },
{ label: 'Agents', href: '#/app-shell/agents', icon: RobotIcon },
{ label: 'Campaigns', href: '#/app-shell/campaigns', icon: MegaphoneIcon },
{ label: 'Waiting list', href: '#/app-shell/waitlist', icon: HourglassIcon, badge: 3 },
],
},
{
label: 'Administration',
items: [
{ label: 'Team', href: '#/app-shell/team', icon: UsersThreeIcon },
{
label: 'Billing',
href: '#/app-shell/billing',
icon: ReceiptIcon,
pages: [
{ label: 'Summary', href: '#/app-shell/billing' },
{ label: 'Invoices', href: '#/app-shell/billing/invoices' },
],
},
],
},
{
label: 'Applications',
items: [
{ label: 'Agenda', href: 'https://agenda.example.com', icon: productIcon('agenda'), target: '_blank' },
{ label: 'Ámbar', href: 'https://ambar.example.com', icon: productIcon('ambar'), target: '_blank' },
],
},
];
const LANGUAGES = [
{ label: 'English', value: 'en' },
{ label: 'Español', value: 'es' },
] as const;
const DashboardFrame = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [colorScheme, setColorScheme] = useState('system');
const [language, setLanguage] = useState<'en' | 'es'>('en');
return (
undefined}>
}>
Profile
}
className="rounded-card h-[520px] overflow-hidden border"
currentPath="#/app-shell/contacts"
header={Contacts }
headerActions={
}
isCollapsed={isCollapsed}
navigation={NAVIGATION}
onCollapsedChange={setIsCollapsed}
organizationSwitcher={
undefined}>
}>
Settings
}
secondaryNavigation={[{ label: 'Settings', href: '#/app-shell/settings', icon: GearSixIcon }]}
>
Contacts
Everyone your agents talked to.
);
};
```
## Source
The examples above are [`src/components/app-shell/app-shell.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/app-shell/app-shell.examples.tsx), which the library's tests render too.
# Card
> A bordered surface that groups one subject: a summary, a settings group, a chart. Compose it from a header (title, description, action), content and a footer; leave out any part.
Source: https://epidermis.getarbol.com/components/layout/card/ · Markdown: https://epidermis.getarbol.com/components/layout/card/index.md
## Settings group
```tsx
import { Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@arbol-ai/ui';
Business hours
When the agent books appointments.
Edit
Monday to Friday, 7:00 to 18:00
Times are in the clinic’s time zone.
```
## Summary
```tsx
import { Badge, Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle, Link } from '@arbol-ai/ui';
Reminder campaign
Running
1,284
contacts called this week
Open the campaign
```
## Source
The examples above are [`src/components/card/card.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/card/card.examples.tsx), which the library's tests render too.
# Disclosure
> A section that folds away, with a hint of what is inside while closed. Use it for the optional part of a form or advanced settings; stack several in a DisclosureGroup for an accordion. Leave out the title to draw your own trigger.
Source: https://epidermis.getarbol.com/components/layout/disclosure/ · Markdown: https://epidermis.getarbol.com/components/layout/disclosure/index.md
## Optional fields
```tsx
import { Disclosure } from '@arbol-ai/ui';
Email
Notes
```
## Accordion
One section open at a time; allowsMultipleExpanded lets several stay open.
```tsx
import { Disclosure, DisclosureGroup } from '@arbol-ai/ui';
Monday to Friday, 7:00 to 19:00. Saturdays until 13:00.
December 25, January 1 and May 1: closed.
A WhatsApp message the day before each appointment.
```
## Your own trigger
Without a title, DisclosureTrigger holds whatever the head shows and DisclosureCaret turns as it opens; CollapsibleCard is this, ready made.
```tsx
import { SlidersHorizontalIcon } from '@phosphor-icons/react';
import { Disclosure, DisclosureCaret, DisclosurePanel, DisclosureTrigger } from '@arbol-ai/ui';
Advanced settings
Line, retries and call limits
Calls from the clinic’s main line.
Two retries, an hour apart.
```
## Text toggle
The trigger’s children may be a function of isExpanded, for words that change as it opens.
```tsx
import { Disclosure, DisclosureCaret, DisclosurePanel, DisclosureTrigger } from '@arbol-ai/ui';
{({ isExpanded }) => (
<>
{isExpanded ? 'Hide advanced options' : 'Show advanced options'}
>
)}
Two retries, an hour apart.
```
## Source
The examples above are [`src/components/disclosure/disclosure.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/disclosure/disclosure.examples.tsx), which the library's tests render too.
# Page
> The frame of every screen: a centered column, a header with the title, what the page is for and its actions, and the content below. Use DetailHeader for the page of one record.
Source: https://epidermis.getarbol.com/components/layout/page/ · Markdown: https://epidermis.getarbol.com/components/layout/page/index.md
## List page
On a phone the actions move under the title; hideOnPhone drops a description that only repeats the title.
```tsx
import { PlusIcon } from '@phosphor-icons/react';
import { Button, PageActions, PageContainer, PageContent, PageDescription, PageHeader, PageTitle } from '@arbol-ai/ui';
Campaigns
Outbound calls and WhatsApp messages your agents send for you.
Export
New campaign
The campaigns list goes here.
```
## Record page
The same rhythm as a list, so opening a record never shifts the title.
```tsx
import { PhoneIcon } from '@phosphor-icons/react';
import { Button, DetailHeader, DetailHeaderContent, DetailHeaderInfo, DetailHeaderMeta, PageActions, PageContainer, PageTitle } from '@arbol-ai/ui';
LG
Laura Gómez
+57 300 555 0101 · laura.gomez@example.com · Contact since May 4
Edit
Call
```
## Source
The examples above are [`src/components/page/page.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/page/page.examples.tsx), which the library's tests render too.
# TanStack Router
> The router entry: RouterUIProvider routes every href the components render through TanStack Router, and useUnsavedChangesBlocker asks before a page with a draft is left. Each example runs its own router in memory.
Source: https://epidermis.getarbol.com/components/layout/router/ · Markdown: https://epidermis.getarbol.com/components/layout/router/index.md
## Links that route
Each link navigates client-side; its href is the URL the router shows.
```tsx
import { Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useLocale } from 'react-aria-components';
import { Link } from '@arbol-ai/ui';
import { RouterUIProvider } from '@arbol-ai/ui/router';
const LINK_LABELS: Record = {
'/': 'Overview',
'/campaigns': 'Campaigns',
'/contacts': 'Contacts',
'/settings': 'Settings',
};
interface MemoryRouterExampleProps {
initialPath: string;
pages: Record ReactNode>;
}
const MemoryRouterExample = ({ initialPath, pages }: MemoryRouterExampleProps) => {
const router = useMemo(() => createExampleRouter(initialPath, pages), [initialPath, pages]);
return ;
};
const createExampleRouter = (initialPath: string, pages: Record ReactNode>) => {
const paths = Object.keys(pages);
const rootRoute = createRootRoute({ component: () => });
const routes = Object.entries(pages).map(([path, component]) =>
createRoute({ component, getParentRoute: () => rootRoute, path })
);
return createRouter({
history: createMemoryHistory({ initialEntries: [initialPath] }),
routeTree: rootRoute.addChildren(routes),
});
};
const ExampleRoot = ({ paths }: { paths: string[] }) => {
const { locale } = useLocale();
return (
{paths.map((path) => (
{LINK_LABELS[path]}
))}
);
};
const OverviewPage = () => Today: 18 appointments and 3 conversations waiting.
;
const ContactsPage = () => 1,284 contacts, 46 added this week.
;
const CampaignsPage = () => 2 campaigns running, 1 scheduled for Monday.
;
const OVERVIEW_PAGES = { '/': OverviewPage, '/contacts': ContactsPage, '/campaigns': CampaignsPage };
```
## Unsaved changes
Change the clinic’s name, then go to Contacts: the page asks first. Save, and it lets you go.
```tsx
import { Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocale } from 'react-aria-components';
import { Button, Link, TextField, UnsavedChangesDialog } from '@arbol-ai/ui';
import { RouterUIProvider, useUnsavedChangesBlocker } from '@arbol-ai/ui/router';
const LINK_LABELS: Record = {
'/': 'Overview',
'/campaigns': 'Campaigns',
'/contacts': 'Contacts',
'/settings': 'Settings',
};
interface MemoryRouterExampleProps {
initialPath: string;
pages: Record ReactNode>;
}
const MemoryRouterExample = ({ initialPath, pages }: MemoryRouterExampleProps) => {
const router = useMemo(() => createExampleRouter(initialPath, pages), [initialPath, pages]);
return ;
};
const createExampleRouter = (initialPath: string, pages: Record ReactNode>) => {
const paths = Object.keys(pages);
const rootRoute = createRootRoute({ component: () => });
const routes = Object.entries(pages).map(([path, component]) =>
createRoute({ component, getParentRoute: () => rootRoute, path })
);
return createRouter({
history: createMemoryHistory({ initialEntries: [initialPath] }),
routeTree: rootRoute.addChildren(routes),
});
};
const ExampleRoot = ({ paths }: { paths: string[] }) => {
const { locale } = useLocale();
return (
{paths.map((path) => (
{LINK_LABELS[path]}
))}
);
};
const SettingsPage = () => {
const [saved, setSaved] = useState('Clínica Norte');
const [name, setName] = useState(saved);
const blocker = useUnsavedChangesBlocker(name !== saved);
return (
{
event.preventDefault();
setSaved(name);
}}
>
Save
);
};
const ContactsPage = () => 1,284 contacts, 46 added this week.
;
const SETTINGS_PAGES = { '/settings': SettingsPage, '/contacts': ContactsPage };
```
## Source
The examples above are [`src/router/router.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/router/router.examples.tsx), which the library's tests render too.
# Save bar
> The foot of a page form: Save, Discard and what is pending, floating above the bottom of the page while something is unsaved. A clean form shows no bar, only what the last save did.
Source: https://epidermis.getarbol.com/components/layout/save-bar/ · Markdown: https://epidermis.getarbol.com/components/layout/save-bar/index.md
## Settings form
Change the name to see the bar; Save keeps the focus while it saves. Clear the name and save to see a failed save.
```tsx
import { useState } from 'react';
import { SaveBar } from '@arbol-ai/ui';
const SAVED_NAME = 'Clínica Norte';
const SettingsForm = () => {
const [name, setName] = useState(SAVED_NAME);
const [savedName, setSavedName] = useState(SAVED_NAME);
const [isPending, setIsPending] = useState(false);
const [idle, setIdle] = useState();
const [error, setError] = useState(null);
return (
{
event.preventDefault();
if (name.trim() === '') {
setError('We couldn’t save: the clinic needs a name.');
return;
}
setIsPending(true);
setTimeout(() => {
setSavedName(name);
setIsPending(false);
setIdle('All changes saved.');
}, 1200);
}}
>
Clinic name
{
setName(event.target.value);
setError(null);
}}
value={name}
/>
{
setName(savedName);
setError(null);
}}
/>
);
};
```
## Source
The examples above are [`src/components/save-bar/save-bar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/save-bar/save-bar.examples.tsx), which the library's tests render too.
# Section header
> Names a section of a page: SectionHeader for a titled section with its one action, SectionEyebrow for the quiet uppercase name of a panel.
Source: https://epidermis.getarbol.com/components/layout/section-header/ · Markdown: https://epidermis.getarbol.com/components/layout/section-header/index.md
## Section with an action
```tsx
import { Button, SectionHeader } from '@arbol-ai/ui';
Add hours
}
description="When your agents answer calls and book appointments."
title="Opening hours"
/>
```
## Panel eyebrow
For a panel whose content carries the weight, such as a feed of recent calls.
```tsx
import { SectionEyebrow } from '@arbol-ai/ui';
Laura Gómez booked a check-up for Tuesday.
```
## Source
The examples above are [`src/components/section-header/section-header.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/section-header/section-header.examples.tsx), which the library's tests render too.
# Separator
> A hairline between two groups of content: sections of a card, or groups of buttons in a toolbar. A vertical separator stretches to the height of its row.
Source: https://epidermis.getarbol.com/components/layout/separator/ · Markdown: https://epidermis.getarbol.com/components/layout/separator/index.md
## Horizontal
```tsx
import { Separator } from '@arbol-ai/ui';
Clínica Norte
Calle 93 #11-27, Bogotá
Open Monday to Friday, 7:00 to 18:00
```
## Vertical
```tsx
import { Button, Separator } from '@arbol-ai/ui';
Archive
Assign
Export
```
## Source
The examples above are [`src/components/separator/separator.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/separator/separator.examples.tsx), which the library's tests render too.
# Settings
> Settings pages: SettingGroup frames related settings as one card, SettingRow pairs a setting’s name and explanation with its control, and SettingToggle is an on/off setting whose whole row toggles.
Source: https://epidermis.getarbol.com/components/layout/settings/ · Markdown: https://epidermis.getarbol.com/components/layout/settings/index.md
## Agent settings
```tsx
import { useState } from 'react';
import { Button, SettingGroup, SettingToggle } from '@arbol-ai/ui';
const AgentSettings = () => {
const [endOnSilence, setEndOnSilence] = useState(true);
return (
Reset
}
description="How the front desk agent behaves on a call."
title="Conversation"
>
);
};
```
## Control under the label
Stacked for wide controls; responsive stacks only on phones.
```tsx
import { Button, SettingGroup, SettingRow } from '@arbol-ai/ui';
Choose people
```
## Source
The examples above are [`src/components/settings/settings.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/settings/settings.examples.tsx), which the library's tests render too.
# Navigation
> The navigation components of @arbol-ai/ui: Breadcrumbs, Link, Pagination, Tabs.
Source: https://epidermis.getarbol.com/components/navigation/ · Markdown: https://epidermis.getarbol.com/components/navigation/index.md
- [Breadcrumbs](https://epidermis.getarbol.com/components/navigation/breadcrumbs/): Where the page sits: links up the hierarchy and the current page last, in a navigation landmark. With collapseOnPhone, a phone shows only the current page and its parent as a back arrow.
- [Link](https://epidermis.getarbol.com/components/navigation/link/): Text that navigates: inside a sentence or on its own. Internal links go through the app router; links that open a new tab protect their opener. Use ButtonLink when navigation should look like an action.
- [Pagination](https://epidermis.getarbol.com/components/navigation/pagination/): The footer of a list: Pagination pages a list the browser holds ("16–30 of 120"); CursorPagination pages a server list that only knows whether another page exists. Both render nothing when there is nowhere to go.
- [Tabs](https://epidermis.getarbol.com/components/navigation/tabs/): Sections of one page or record. Arrow keys move between tabs, the thumb slides to the selection and the panel height follows its content. Keep the selected tab in the URL; give tabs an href when each section is its own route.
# Breadcrumbs
> Where the page sits: links up the hierarchy and the current page last, in a navigation landmark. With collapseOnPhone, a phone shows only the current page and its parent as a back arrow.
Source: https://epidermis.getarbol.com/components/navigation/breadcrumbs/ · Markdown: https://epidermis.getarbol.com/components/navigation/breadcrumbs/index.md
## A record in a section
```tsx
import { Breadcrumb, Breadcrumbs } from '@arbol-ai/ui';
Campaigns
Spring reminders
Recipients
```
## Collapsed on phones
Narrow the window below 640px: the parent becomes a back arrow before the page name.
```tsx
import { Breadcrumb, Breadcrumbs } from '@arbol-ai/ui';
Settings
Patients
Ana Torres
```
## Source
The examples above are [`src/components/breadcrumbs/breadcrumbs.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/breadcrumbs/breadcrumbs.examples.tsx), which the library's tests render too.
# Link
> Text that navigates: inside a sentence or on its own. Internal links go through the app router; links that open a new tab protect their opener. Use ButtonLink when navigation should look like an action.
Source: https://epidermis.getarbol.com/components/navigation/link/ · Markdown: https://epidermis.getarbol.com/components/navigation/link/index.md
## Inside a sentence
```tsx
import { Link } from '@arbol-ai/ui';
No campaigns are running. Create a campaign to start calling your
contacts.
```
## Muted
For secondary navigation that should not draw the eye.
```tsx
import { Link } from '@arbol-ai/ui';
Recent appointments
View all
```
## Plain
No color, weight or underline of its own, for a link styled by its classes or its content, such as a name in a table.
```tsx
import { Link } from '@arbol-ai/ui';
Front desk agent
12 calls today
```
## Opens a new tab
target="_blank" adds rel="noopener noreferrer" unless the link sets its own rel.
```tsx
import { Link } from '@arbol-ai/ui';
The React Aria Link documentation
```
## Source
The examples above are [`src/components/link/link.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/link/link.examples.tsx), which the library's tests render too.
# Pagination
> The footer of a list: Pagination pages a list the browser holds ("16–30 of 120"); CursorPagination pages a server list that only knows whether another page exists. Both render nothing when there is nowhere to go.
Source: https://epidermis.getarbol.com/components/navigation/pagination/ · Markdown: https://epidermis.getarbol.com/components/navigation/pagination/index.md
## A list in the browser
```tsx
import { useState } from 'react';
import { PAGE_SIZE, Pagination } from '@arbol-ai/ui';
const CONTACTS = Array.from({ length: 52 }, (_, index) => `Contact ${index + 1}`);
const ContactsExample = () => {
const [page, setPage] = useState(1);
return (
{CONTACTS.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map((contact) => (
{contact}
))}
);
};
```
## A server list with cursors
While a page loads, the pressed button waits with a spinner and keeps focus.
```tsx
import { useState } from 'react';
import { CursorPagination } from '@arbol-ai/ui';
const ConversationsExample = () => {
const [page, setPage] = useState(1);
const [isFetching, setIsFetching] = useState(false);
const go = (next: number) => {
setIsFetching(true);
setTimeout(() => {
setPage(next);
setIsFetching(false);
}, 800);
};
return (
1}
isFetching={isFetching}
onNext={() => {
go(page + 1);
}}
onPrevious={() => {
go(page - 1);
}}
page={page}
/>
);
};
```
## Source
The examples above are [`src/components/pagination/pagination.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/pagination/pagination.examples.tsx), which the library's tests render too.
# Tabs
> Sections of one page or record. Arrow keys move between tabs, the thumb slides to the selection and the panel height follows its content. Keep the selected tab in the URL; give tabs an href when each section is its own route.
Source: https://epidermis.getarbol.com/components/navigation/tabs/ · Markdown: https://epidermis.getarbol.com/components/navigation/tabs/index.md
## Sections with counts
The simple form: a tabs list, and one TabPanel per tab. A count of 0 is hidden.
```tsx
import { useState } from 'react';
import { TabPanel, Tabs } from '@arbol-ai/ui';
const PatientTabs = () => {
const [tab, setTab] = useState('appointments');
return (
Mon 14 · Annual checkup
Thu 17 · Blood test
Prefers morning appointments. Allergic to penicillin.
No files yet.
);
};
```
## Icons and a kept draft
keepMounted keeps a panel (and the note typed in it) while another tab shows, hidden and out of reach.
```tsx
import { CalendarBlankIcon, FileTextIcon, NotePencilIcon } from '@phosphor-icons/react';
import { TabPanel, Tabs } from '@arbol-ai/ui';
},
{ id: 'notes', label: 'Notes', icon: },
{ id: 'files', label: 'Files', icon: , isDisabled: true },
]}
>
Follow-up with Dr. Ruiz, Monday at 9:30.
No files.
```
## Composed
TabList, Tab, TabPanels and TabPanel, for tabs with content of their own.
```tsx
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@arbol-ai/ui';
Recipients{' '}
New
Schedule
1,240 contacts from the March list.
Weekdays from 9:00 to 17:00.
```
## Source
The examples above are [`src/components/tabs/tabs.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/tabs/tabs.examples.tsx), which the library's tests render too.
# Overlays
> The overlays components of @arbol-ai/ui: CommandPalette, ConfirmDialog, InfoTip, Menu, Overlay, Popover, RowMenu, Tooltip.
Source: https://epidermis.getarbol.com/components/overlays/ · Markdown: https://epidermis.getarbol.com/components/overlays/index.md
- [CommandPalette](https://epidermis.getarbol.com/components/overlays/command-palette/): Search and commands in one modal that Ctrl+K (⌘K) opens from anywhere, and the list of keyboard shortcuts that ? opens.
- [ConfirmDialog](https://epidermis.getarbol.com/components/overlays/confirm-dialog/): The one question before something irreversible: Cancel is focused, the action is red, and a failure stays in the question. For the gravest actions, the person types the name first.
- [InfoTip](https://epidermis.getarbol.com/components/overlays/info-tip/): A small round ⓘ beside a label that explains it in plain words. It opens on a click, a tap or the keyboard, so it reaches touch screens, unlike a tooltip.
- [Menu](https://epidermis.getarbol.com/components/overlays/menu/): A dropdown of actions or options on the popover surface: icons, a second line, shortcuts, links, submenus, and radio or checkbox items.
- [Overlay](https://epidermis.getarbol.com/components/overlays/overlay/): Every modal dialog, side sheet and alert question. It never loses what someone typed by accident: closing with unsaved input asks first, and nothing closes it while a save runs.
- [Popover](https://epidermis.getarbol.com/components/overlays/popover/): A floating panel anchored to its trigger, for filters, a quick form or details that do not deserve a dialog. Focus moves in, and Esc or a click outside closes it.
- [RowMenu](https://epidermis.getarbol.com/components/overlays/row-menu/): The "⋯" of a row or a record: its everyday actions without opening it, destructive ones last and in red. A press on it never opens the row.
- [Tooltip](https://epidermis.getarbol.com/components/overlays/tooltip/): A short hint that opens when its trigger is hovered or focused. It never opens on touch screens, so the trigger must make sense without it; use InfoTip for explanations people tap to read.
# CommandPalette
> Search and commands in one modal that Ctrl+K (⌘K) opens from anywhere, and the list of keyboard shortcuts that ? opens.
Source: https://epidermis.getarbol.com/components/overlays/command-palette/ · Markdown: https://epidermis.getarbol.com/components/overlays/command-palette/index.md
## Command palette
```tsx
import { ChartBarIcon, GearIcon, MegaphoneIcon, UserIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button, CommandPalette } from '@arbol-ai/ui';
const PaletteExample = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
Search
, href: '#/command-palette' },
{ id: 'campaigns', label: 'Campaigns', icon: , href: '#/command-palette' },
{ id: 'reports', label: 'Reports', icon: , href: '#/command-palette' },
{ id: 'settings', label: 'Settings', icon: , href: '#/command-palette' },
],
},
]}
isOpen={isOpen}
onOpenChange={setIsOpen}
recent={[{ id: 'ana', label: 'Ana Torres', description: '+57 300 555 0101', icon: }]}
/>
>
);
};
```
## Keyboard shortcuts
```tsx
import { useState } from 'react';
import { Button, ShortcutsDialog } from '@arbol-ai/ui';
const ShortcutsExample = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
Keyboard shortcuts
>
);
};
```
## Source
The examples above are [`src/components/command-palette/command-palette.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/command-palette/command-palette.examples.tsx), which the library's tests render too.
# ConfirmDialog
> The one question before something irreversible: Cancel is focused, the action is red, and a failure stays in the question. For the gravest actions, the person types the name first.
Source: https://epidermis.getarbol.com/components/overlays/confirm-dialog/ · Markdown: https://epidermis.getarbol.com/components/overlays/confirm-dialog/index.md
## Delete
```tsx
import { useState } from 'react';
import { Button, ConfirmDialog } from '@arbol-ai/ui';
const DeleteExample = ({
title = 'Delete Ana Torres?',
confirmationText,
}: {
title?: string;
confirmationText?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
Delete
{
setIsPending(true);
setTimeout(() => {
setIsPending(false);
close();
}, 1200);
}}
onOpenChange={setIsOpen}
title={title}
/>
>
);
};
```
## Type to confirm
```tsx
import { useState } from 'react';
import { Button, ConfirmDialog } from '@arbol-ai/ui';
const DeleteExample = ({
title = 'Delete Ana Torres?',
confirmationText,
}: {
title?: string;
confirmationText?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
return (
<>
{
setIsOpen(true);
}}
variant="outline"
>
Delete
{
setIsPending(true);
setTimeout(() => {
setIsPending(false);
close();
}, 1200);
}}
onOpenChange={setIsOpen}
title={title}
/>
>
);
};
```
## Source
The examples above are [`src/components/overlay/confirm-dialog.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/overlay/confirm-dialog.examples.tsx), which the library's tests render too.
# InfoTip
> A small round ⓘ beside a label that explains it in plain words. It opens on a click, a tap or the keyboard, so it reaches touch screens, unlike a tooltip.
Source: https://epidermis.getarbol.com/components/overlays/info-tip/ · Markdown: https://epidermis.getarbol.com/components/overlays/info-tip/index.md
## Beside a metric
```tsx
import { InfoTip } from '@arbol-ai/ui';
Answer rate
The share of calls the agent answered within three rings, over the selected dates.
94%
```
## A longer explanation
```tsx
import { InfoTip } from '@arbol-ai/ui';
Business hours
Outside these hours the agent still answers, but it books no appointments.
Holidays follow the calendar of the clinic’s country.
```
## Source
The examples above are [`src/components/info-tip/info-tip.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/info-tip/info-tip.examples.tsx), which the library's tests render too.
# Menu
> A dropdown of actions or options on the popover surface: icons, a second line, shortcuts, links, submenus, and radio or checkbox items.
Source: https://epidermis.getarbol.com/components/overlays/menu/ · Markdown: https://epidermis.getarbol.com/components/overlays/menu/index.md
## Actions
```tsx
import { CopySimpleIcon, DotsThreeIcon, FolderIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { Button, Menu, MenuItem, MenuSeparator, MenuTrigger, SubmenuTrigger } from '@arbol-ai/ui';
} shortcut="E">
Edit
}>
Duplicate
}>Move to
Reminders
Follow-ups
} variant="destructive">
Delete
```
## Options
```tsx
import { useState } from 'react';
import type { Selection } from 'react-aria-components';
import { Button, Menu, MenuHeader, MenuItem, MenuSection, MenuTrigger } from '@arbol-ai/ui';
const ViewMenuExample = () => {
const [sort, setSort] = useState(new Set(['recent']));
const [columns, setColumns] = useState(new Set(['phone', 'agent']));
return (
View
Sort by
Most recent
Name
Columns
Phone
Agent
Channel
);
};
```
## Source
The examples above are [`src/components/menu/menu.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/menu/menu.examples.tsx), which the library's tests render too.
# Overlay
> Every modal dialog, side sheet and alert question. It never loses what someone typed by accident: closing with unsaved input asks first, and nothing closes it while a save runs.
Source: https://epidermis.getarbol.com/components/overlays/overlay/ · Markdown: https://epidermis.getarbol.com/components/overlays/overlay/index.md
## Dialog with a form
Rendered while open: type something, then press Esc or Cancel to see the discard question. Save leaves without asking.
```tsx
import { useState } from 'react';
import { Button, Overlay, OverlayBody, OverlayDescription, OverlayFooter, OverlayHeader, OverlayTitle, TextField } from '@arbol-ai/ui';
const FormDialogExample = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{
setIsOpen(true);
}}
>
New contact
{isOpen ? (
{
setIsOpen(false);
}}
>
{(close, leave) => (
{
event.preventDefault();
leave();
}}
>
New contact
They join the contact list and can receive campaigns.
Cancel
Save contact
)}
) : null}
>
);
};
```
## Side sheet from a trigger
A record in context, opened by OverlayTrigger; focus returns to the button.
```tsx
import { Button, Overlay, OverlayBody, OverlayDescription, OverlayFooter, OverlayHeader, OverlayTitle, OverlayTrigger } from '@arbol-ai/ui';
Open appointment
Thursday 10:30, Ana Torres
General check-up with Dr. Ruiz.
Confirmed by WhatsApp yesterday at 6:12 pm.
Register arrival
```
## Source
The examples above are [`src/components/overlay/overlay.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/overlay/overlay.examples.tsx), which the library's tests render too.
# Popover
> A floating panel anchored to its trigger, for filters, a quick form or details that do not deserve a dialog. Focus moves in, and Esc or a click outside closes it.
Source: https://epidermis.getarbol.com/components/overlays/popover/ · Markdown: https://epidermis.getarbol.com/components/overlays/popover/index.md
## Quick form
```tsx
import { Button, Popover, PopoverTrigger, TextField } from '@arbol-ai/ui';
Rename campaign
{({ close }) => (
Save
)}
```
## With an arrow
```tsx
import { Button, Popover, PopoverTrigger } from '@arbol-ai/ui';
What is a contact rate?
The share of contacts who answered at least once in the period.
```
## Source
The examples above are [`src/components/popover/popover.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/popover/popover.examples.tsx), which the library's tests render too.
# RowMenu
> The "⋯" of a row or a record: its everyday actions without opening it, destructive ones last and in red. A press on it never opens the row.
Source: https://epidermis.getarbol.com/components/overlays/row-menu/ · Markdown: https://epidermis.getarbol.com/components/overlays/row-menu/index.md
## In a list row
```tsx
import { PencilSimpleIcon, PhoneIcon, ProhibitIcon, WhatsappLogoIcon } from '@phosphor-icons/react';
import { RowMenu } from '@arbol-ai/ui';
Ana Torres · +57 300 555 0101
},
{ label: 'Call', icon:
, href: 'tel:+573005550101' },
{
label: 'WhatsApp',
icon:
,
href: 'https://wa.me/573005550101',
target: '_blank',
},
{ label: 'Deactivate', icon:
, isDestructive: true },
]}
label="Actions for Ana Torres"
/>
```
## Source
The examples above are [`src/components/row-menu/row-menu.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/row-menu/row-menu.examples.tsx), which the library's tests render too.
# Tooltip
> A short hint that opens when its trigger is hovered or focused. It never opens on touch screens, so the trigger must make sense without it; use InfoTip for explanations people tap to read.
Source: https://epidermis.getarbol.com/components/overlays/tooltip/ · Markdown: https://epidermis.getarbol.com/components/overlays/tooltip/index.md
## On an icon button
```tsx
import { DownloadSimpleIcon } from '@phosphor-icons/react';
import { Button, Tooltip, TooltipTrigger } from '@arbol-ai/ui';
Download the contacts as CSV
```
## With a shortcut
```tsx
import { ArchiveIcon } from '@phosphor-icons/react';
import { Button, Kbd, Tooltip, TooltipTrigger } from '@arbol-ai/ui';
Archive E
```
## Placement
It opens above its trigger by default and flips when there is no room.
```tsx
import { PauseIcon } from '@phosphor-icons/react';
import { Button, Tooltip, TooltipTrigger } from '@arbol-ai/ui';
Pause the agent
New calls wait until you resume it
```
## Source
The examples above are [`src/components/tooltip/tooltip.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/tooltip/tooltip.examples.tsx), which the library's tests render too.
# Pickers
> The pickers components of @arbol-ai/ui: Calendar, ColorSwatchPicker, DateField, DatePicker, DateRangeFilter, FileUploadButton, PhoneField.
Source: https://epidermis.getarbol.com/components/pickers/ · Markdown: https://epidermis.getarbol.com/components/pickers/index.md
- [Calendar](https://epidermis.getarbol.com/components/pickers/calendar/): A month grid to pick a day, or a period with RangeCalendar. The locale names the months and days; firstDayOfWeek overrides where the week starts. Use DatePicker in forms, where people can also type the date.
- [ColorSwatchPicker](https://epidermis.getarbol.com/components/pickers/color-swatch-picker/): A palette to choose a color from, such as a service’s color in an agenda. The palette is the app’s; allowsCustomColor adds the system color chooser.
- [DateField](https://epidermis.getarbol.com/components/pickers/date-field/): A date or a time typed segment by segment, in the order and format of the locale. Use DatePicker when people should also see a calendar.
- [DatePicker](https://epidermis.getarbol.com/components/pickers/date-picker/): A date for a form, typed in the locale’s format or chosen on a calendar; DateRangePicker does the same for a period. For a dashboard’s period filter, use DateRangeFilter.
- [DateRangeFilter](https://epidermis.getarbol.com/components/pickers/date-range-filter/): The period filter of a dashboard: preset windows ending today and a custom range on a calendar, with a button to clear it. The value is plain days, so the app can keep it in the URL.
- [FileUploadButton](https://epidermis.getarbol.com/components/pickers/file-trigger/): Choosing files: a button that opens the file chooser or the camera, a drop zone, and a progress row per upload. They upload nothing; the app sends the files.
- [PhoneField](https://epidermis.getarbol.com/components/pickers/phone-field/): A phone number with its country: a searchable country list beside a box that groups the digits. The value is always the international form, such as +573001234567.
# Calendar
> A month grid to pick a day, or a period with RangeCalendar. The locale names the months and days; firstDayOfWeek overrides where the week starts. Use DatePicker in forms, where people can also type the date.
Source: https://epidermis.getarbol.com/components/pickers/calendar/ · Markdown: https://epidermis.getarbol.com/components/pickers/calendar/index.md
## Pick a day
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import type { CalendarDate } from '@internationalized/date';
import { useState } from 'react';
import { useDateFormatter } from 'react-aria';
import { Calendar } from '@arbol-ai/ui';
const SingleExample = () => {
const [date, setDate] = useState(() => today(getLocalTimeZone()).add({ days: 3 }));
const formatter = useDateFormatter({ dateStyle: 'full' });
return (
Follow-up call: {formatter.format(date.toDate(getLocalTimeZone()))}
);
};
```
## Pick a period
The first press sets one end, the second the other; the days between form one band.
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import { RangeCalendar } from '@arbol-ai/ui';
const RangeExample = () => {
const now = today(getLocalTimeZone());
return (
);
};
```
## Week starting on Monday, weekends unavailable
Days before today are disabled and weekends are struck through: both stay out of reach of a booking.
```tsx
import { getLocalTimeZone, isWeekend, today } from '@internationalized/date';
import { useLocale } from 'react-aria-components';
import { Calendar } from '@arbol-ai/ui';
const BookingExample = () => {
const { locale } = useLocale();
return (
isWeekend(date, locale)}
minValue={today(getLocalTimeZone())}
/>
);
};
```
## Source
The examples above are [`src/components/calendar/calendar.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/calendar/calendar.examples.tsx), which the library's tests render too.
# ColorSwatchPicker
> A palette to choose a color from, such as a service’s color in an agenda. The palette is the app’s; allowsCustomColor adds the system color chooser.
Source: https://epidermis.getarbol.com/components/pickers/color-swatch-picker/ · Markdown: https://epidermis.getarbol.com/components/pickers/color-swatch-picker/index.md
## Default palette
Without names, screen readers describe each color in the locale.
```tsx
import { useState } from 'react';
import { ColorSwatchPicker } from '@arbol-ai/ui';
const DefaultExample = () => {
const [color, setColor] = useState(null);
return ;
};
```
## App palette with a custom color
```tsx
import { useState } from 'react';
import { ColorSwatchPicker } from '@arbol-ai/ui';
const SERVICE_COLORS = [
{ value: '#00877c', name: 'Teal' },
{ value: '#2971c6', name: 'Blue' },
{ value: '#884bc0', name: 'Violet' },
{ value: '#c63c8a', name: 'Pink' },
{ value: '#be8700', name: 'Amber' },
];
const CustomExample = () => {
const [color, setColor] = useState('#2971c6');
return (
);
};
```
## Palette keys drawn with theme colors
The app stores a key (blue) and each swatch shows a theme variable, so it follows light and dark mode.
```tsx
import { useState } from 'react';
import { ColorSwatchPicker } from '@arbol-ai/ui';
const PROFESSIONAL_COLORS = [
{ value: 'blue', color: 'var(--color-blue-9)', name: 'Blue' },
{ value: 'green', color: 'var(--color-green-9)', name: 'Green' },
{ value: 'amber', color: 'var(--color-amber-9)', name: 'Amber' },
{ value: 'red', color: 'var(--color-red-9)', name: 'Red' },
{ value: 'gray', color: 'var(--color-gray-9)', name: 'Gray' },
];
const KeysExample = () => {
const [color, setColor] = useState('blue');
return (
);
};
```
## Source
The examples above are [`src/components/color-swatch-picker/color-swatch-picker.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/color-swatch-picker/color-swatch-picker.examples.tsx), which the library's tests render too.
# DateField
> A date or a time typed segment by segment, in the order and format of the locale. Use DatePicker when people should also see a calendar.
Source: https://epidermis.getarbol.com/components/pickers/date-field/ · Markdown: https://epidermis.getarbol.com/components/pickers/date-field/index.md
## Date
```tsx
import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date';
import { DateField } from '@arbol-ai/ui';
```
## Time in 24 hours
hourCycle overrides the locale, for an app that always shows 24 hours.
```tsx
import { Time } from '@internationalized/date';
import { TimeField } from '@arbol-ai/ui';
```
## Invalid
```tsx
import { DateField } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/date-field/date-field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/date-field/date-field.examples.tsx), which the library's tests render too.
# DatePicker
> A date for a form, typed in the locale’s format or chosen on a calendar; DateRangePicker does the same for a period. For a dashboard’s period filter, use DateRangeFilter.
Source: https://epidermis.getarbol.com/components/pickers/date-picker/ · Markdown: https://epidermis.getarbol.com/components/pickers/date-picker/index.md
## Date
```tsx
import { getLocalTimeZone, isWeekend, today } from '@internationalized/date';
import { useLocale } from 'react-aria-components';
import { DatePicker } from '@arbol-ai/ui';
const AppointmentExample = () => {
const { locale } = useLocale();
return (
isWeekend(date, locale)}
label="Appointment date"
minValue={today(getLocalTimeZone())}
/>
);
};
```
## Date and time in 24 hours
```tsx
import { getLocalTimeZone, now } from '@internationalized/date';
import { DatePicker } from '@arbol-ai/ui';
```
## Period
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import { DateRangePicker } from '@arbol-ai/ui';
() => {
const start = today(getLocalTimeZone());
return (
);
}
```
## Source
The examples above are [`src/components/date-picker/date-picker.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/date-picker/date-picker.examples.tsx), which the library's tests render too.
# DateRangeFilter
> The period filter of a dashboard: preset windows ending today and a custom range on a calendar, with a button to clear it. The value is plain days, so the app can keep it in the URL.
Source: https://epidermis.getarbol.com/components/pickers/date-range-filter/ · Markdown: https://epidermis.getarbol.com/components/pickers/date-range-filter/index.md
## Field
A full-width trigger for a filter panel or a form.
```tsx
import type { CalendarDate } from '@internationalized/date';
import { useState } from 'react';
import type { RangeValue } from 'react-aria-components';
import { DateRangeFilter } from '@arbol-ai/ui';
const FieldExample = () => {
const [period, setPeriod] = useState | null>(null);
return (
);
};
```
## Chip in a filter row
The compact look beside other filters; it names the preset while the dates match it.
```tsx
import { getLocalTimeZone, today } from '@internationalized/date';
import type { CalendarDate } from '@internationalized/date';
import { useState } from 'react';
import type { RangeValue } from 'react-aria-components';
import { DateRangeFilter } from '@arbol-ai/ui';
const ChipExample = () => {
const now = today(getLocalTimeZone());
const [period, setPeriod] = useState | null>({
start: now.subtract({ days: 6 }),
end: now,
});
return (
);
};
```
## Source
The examples above are [`src/components/date-range-filter/date-range-filter.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/date-range-filter/date-range-filter.examples.tsx), which the library's tests render too.
# FileUploadButton
> Choosing files: a button that opens the file chooser or the camera, a drop zone, and a progress row per upload. They upload nothing; the app sends the files.
Source: https://epidermis.getarbol.com/components/pickers/file-trigger/ · Markdown: https://epidermis.getarbol.com/components/pickers/file-trigger/index.md
## Buttons
The camera button suits phones; hide it on larger screens with a class.
```tsx
import { useState } from 'react';
import { FileUploadButton } from '@arbol-ai/ui';
const ButtonsExample = () => {
const [chosen, setChosen] = useState([]);
const choose = (files: File[]) => {
setChosen(files.map((file) => file.name));
};
return (
{chosen.length > 0 ? chosen.join(', ') : 'No file chosen yet.'}
);
};
```
## Drop zone with progress
```tsx
import { useState } from 'react';
import { DropZone, UploadProgress } from '@arbol-ai/ui';
const UploadExample = () => {
const [names, setNames] = useState(['authorization.pdf']);
return (
{
setNames([...names, ...files.map((file) => file.name)]);
}}
/>
{names.map((name) => (
))}
);
};
```
## Source
The examples above are [`src/components/file-trigger/file-trigger.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/file-trigger/file-trigger.examples.tsx), which the library's tests render too.
# PhoneField
> A phone number with its country: a searchable country list beside a box that groups the digits. The value is always the international form, such as +573001234567.
Source: https://epidermis.getarbol.com/components/pickers/phone-field/ · Markdown: https://epidermis.getarbol.com/components/pickers/phone-field/index.md
## With a default country
Local digits take the country’s code; typing a plus picks the country from the number.
```tsx
import { useState } from 'react';
import { PhoneField } from '@arbol-ai/ui';
const ContactExample = () => {
const [phone, setPhone] = useState('');
return (
Stored as: {phone === '' ? '—' : phone}
);
};
```
## Invalid
```tsx
import { PhoneField } from '@arbol-ai/ui';
```
## Source
The examples above are [`src/components/phone-field/phone-field.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/phone-field/phone-field.examples.tsx), which the library's tests render too.
# Get started
> Install @arbol-ai/ui and set up an app with its theme and provider.
Source: https://epidermis.getarbol.com/get-started/ · Markdown: https://epidermis.getarbol.com/get-started/index.md
1. [Installation](https://epidermis.getarbol.com/get-started/installation/): the package, its peers and the optional entries.
2. [Setup](https://epidermis.getarbol.com/get-started/setup/): the theme stylesheet, `UIProvider` or `RouterUIProvider`, toasts, and a
first page.
Then browse the [components](https://epidermis.getarbol.com/components/) and read the [guides](https://epidermis.getarbol.com/guides/).
# Installation
> Install @arbol-ai/ui with its peers, the optional entries, and the agent skill it links into your project.
Source: https://epidermis.getarbol.com/get-started/installation/ · Markdown: https://epidermis.getarbol.com/get-started/installation/index.md
Install the package with React Aria, its two required peers:
```sh
npm i @arbol-ai/ui react-aria-components react-aria
yarn add @arbol-ai/ui react-aria-components react-aria
pnpm add @arbol-ai/ui react-aria-components react-aria
bun add @arbol-ai/ui react-aria-components react-aria
```
## Peers
| Peer | Version | For |
| --- | --- | --- |
| `react`, `react-dom` | 19 | Every component. |
| `react-aria-components` | 1.21 or later | Every component. |
| `react-aria` | 3.52 or later | Every component. |
| `tailwindcss` | 4.1 or later | The theme and the classes the components use. |
| `echarts` | 6 | `@arbol-ai/ui/charts` only. |
| `@tanstack/react-form` | 1 | `@arbol-ai/ui/form` only. |
| `@tanstack/react-router` | 1 | `@arbol-ai/ui/router` only. |
| `@tanstack/react-query` | 5 | `@arbol-ai/ui/query` only. |
Add an optional peer only when the app imports its entry. Screens usually also import
`@phosphor-icons/react` (icons) and `@internationalized/date` (date values): add them when your own code
uses them.
Keep one copy of React Aria in the app (`bun pm ls react-aria-components`): two copies split the locale and
router contexts, and links stop routing.
## The agent skill
Installing the package links its `arbol-ui` skill into your project (`.agents/skills/arbol-ui` and
`.claude/skills/arbol-ui`, pointing into `node_modules`, so it always matches the installed version). Bun
runs a dependency's install script only when the app trusts it: list `@arbol-ai/ui` in
`trustedDependencies` in the app's `package.json`, or link it by hand:
```bash
bunx arbol-ui-skills
```
The same skill is published by this site; see [For agents](https://epidermis.getarbol.com/guides/agents/).
Next: [Setup](https://epidermis.getarbol.com/get-started/setup/).
# Setup
> Import the theme after Tailwind, render UIProvider or RouterUIProvider once at the root, mount the Toaster, and build a first page.
Source: https://epidermis.getarbol.com/get-started/setup/ · Markdown: https://epidermis.getarbol.com/get-started/setup/index.md
1. **Import the theme**
In your entry stylesheet, after Tailwind:
```css title="app.css"
@import 'tailwindcss';
@import '@arbol-ai/ui/theme.css';
```
The theme adds the tokens (`bg-primary`, `text-muted-foreground`, `rounded-field`, `shadow-md`, the gray
and status ramps), the base layer, and an `@source` for the library's files, so Tailwind generates the
classes the components use.
2. **Render the provider**
With TanStack Router, render `RouterUIProvider` once, in the root route's component (it reads the
router, so it goes inside it):
```tsx
import { RouterUIProvider } from '@arbol-ai/ui/router';
import { createRootRoute, Outlet } from '@tanstack/react-router';
export const Route = createRootRoute({
component: () => (
),
});
```
With any other router, render `UIProvider` with your router's navigate:
```tsx
import { UIProvider } from '@arbol-ai/ui';
router.navigate({ ...options, href })}>
;
```
Pass the whole `href` (a path alone drops the query string). Leave `useHref` out unless the router adds
a base path, and then apply it only to hrefs that start with `/`, never to `tel:` or `https:` links.
3. **Mount the toaster**
Render ` ` once inside the provider, usually in the root layout, then call
`toast.success(…)` or `toast.error(…)` from anywhere.
4. **Build a page**
```tsx
import { Button, PageContainer, PageHeader, PageTitle } from '@arbol-ai/ui';
Contacts
New contact
;
```
## The locale
`locale` is the app's language as a BCP 47 tag. React Aria formats dates and numbers with it, and the
library picks its own strings by language: any `es-*` locale reads Spanish, everything else English.
- Paraglide: `locale={getLocale() === 'es' ? 'es-CO' : 'en-US'}`.
- i18next: `locale={i18n.language}`.
- Spanish only: `locale="es-CO"`.
See [Text and languages](https://epidermis.getarbol.com/guides/text-and-languages/).
## Typed hrefs
Every `href` in the library (links, menu items, tabs, table rows, breadcrumbs, navigation) navigates
client-side through the provider. Type them with React Aria's `RouterConfig`, keeping room for external
links, which stay as they are:
```ts
import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
declare module 'react-aria-components' {
interface RouterConfig {
href: ToOptions['to'] | `${'http' | 'https' | 'mailto' | 'tel'}:${string}`;
routerOptions: Omit;
}
}
```
# Guides
> Theming, text and languages, blocks, moving Arbol's apps onto the package, and the library for coding agents.
Source: https://epidermis.getarbol.com/guides/ · Markdown: https://epidermis.getarbol.com/guides/index.md
- [Theming](https://epidermis.getarbol.com/guides/theming/): tokens, dark mode, the mono appearance and fonts.
- [Text and languages](https://epidermis.getarbol.com/guides/text-and-languages/): app copy, the library's own strings, dates and numbers.
- [Blocks](https://epidermis.getarbol.com/guides/blocks/): whole screens and frames, finished, and the parts they are built from.
- [Moving Raíz, Agenda and Account](https://epidermis.getarbol.com/guides/migration/): what differs from the apps' own components.
- [For agents](https://epidermis.getarbol.com/guides/agents/): the agent skill, llms.txt, Markdown pages and the MCP server.
# For agents
> How coding agents learn @arbol-ai/ui - the arbol-ui skill, llms.txt, a Markdown version of every page, Markdown negotiation, the MCP server and WebMCP search.
Source: https://epidermis.getarbol.com/guides/agents/ · Markdown: https://epidermis.getarbol.com/guides/agents/index.md
The library is built to be assembled by coding agents as much as by people. Everything on this site is
published for them too.
## The skill
`arbol-ui` teaches an agent which pattern a flow needs, the anatomy of list, record, settings and analytics
pages, and the rules every screen keeps. Installing the package links it into the project
([Installation](https://epidermis.getarbol.com/get-started/installation/#the-agent-skill)). This site also publishes it at
[`/.well-known/agent-skills/index.json`](https://epidermis.getarbol.com/.well-known/agent-skills/index.json), for agents that install
skills from a site.
## Markdown and llms.txt
- [`/llms.txt`](https://epidermis.getarbol.com/llms.txt) indexes the docs; each section has its own, such as
`/components/llms.txt`.
- [`/llms-full.txt`](https://epidermis.getarbol.com/llms-full.txt) is every page in one document.
- Every page has a Markdown version at `/index.md`. A component page's Markdown carries each example's
title, description and code, importing from the published entries.
- A request to any page's own URL with `Accept: text/markdown` gets that Markdown.
## MCP
`https://epidermis.getarbol.com/mcp` is a read-only MCP server (Streamable HTTP, no key) with two tools:
`list_pages` and `get_page`. Its card is at `/mcp/server-card`. Agents running in the reader's browser can
also search the docs through WebMCP (`search_documentation`).
## In this repository
The library's own agent guide is `CLAUDE.md` at the repository root; the site's is `site/AGENT.md`.
React Aria's documentation for agents is at https://react-aria.adobe.com/llms.txt.
# Blocks
> Whole screens and frames, finished: pass the data, get the page. Each is assembled from exported parts, to use one piece alone or build a variant.
Source: https://epidermis.getarbol.com/guides/blocks/ · Markdown: https://epidermis.getarbol.com/guides/blocks/index.md
Blocks are whole screens and frames, finished: pass the data and callbacks, get the page. A sidebar is the
same logic in every app, so an app passes `navigation` and nothing else; a list is the table block plus
rows and columns. Each block is assembled from parts that are exported too, so you can use one small piece
alone or build a variant from the same parts.
```tsx
import { AppShell, ListPage, ListTable } from '@arbol-ai/ui';
}>
`/contacts/${contact.id}`}
/>
;
```
| Block | What it brings |
| --- | --- |
| [`AppShell`](https://epidermis.getarbol.com/components/layout/app-shell/) and its slots | Sidebar from items, header, back link, companion panel; `AccountMenu`, `OrganizationMenu`, `LogoTile`, `ProductMark` |
| [`ListPage`](https://epidermis.getarbol.com/components/blocks/list-page/), [`ListTable`](https://epidermis.getarbol.com/components/blocks/list-table/) | Toolbar (search, filters, saved views, columns), sorting, row links and menus, every state, pages, bulk actions |
| [`RecordPage`](https://epidermis.getarbol.com/components/blocks/record-page/), [`SettingsPage`](https://epidermis.getarbol.com/components/blocks/settings-page/) | Record header with pager and rename, tabs, properties aside; settings sections, cards, links and save bar |
| [`FormDialog`, `FormSheet`](https://epidermis.getarbol.com/components/blocks/form-dialog/) (`/form`) | A create or edit flow: fields, Cancel and a pending Submit, the server's refusal, the discard guard |
| [`KpiGrid`](https://epidermis.getarbol.com/components/blocks/kpi-grid/), [`ChartCard`](https://epidermis.getarbol.com/components/blocks/chart-card/), [`ReportPage`](https://epidermis.getarbol.com/components/blocks/report-page/) | A period picker, KPI tiles from data, chart cards from series, breakdowns |
| [Route states](https://epidermis.getarbol.com/components/blocks/route-states/) | `RouteErrorState`, `NotFoundState`, `NoAccessState`, `AppLoadingState`, and TanStack Router adapters in `/router` |
Tables, forms, routing, data fetching and virtualization are all TanStack: Table (`DataTable`), Form,
Router, Query and Virtual (`PickList`).
# Moving Raíz, Agenda and Account
> What changes for Arbol's three dashboards when they move from their own components/ui folders onto @arbol-ai/ui.
Source: https://epidermis.getarbol.com/guides/migration/ · Markdown: https://epidermis.getarbol.com/guides/migration/index.md
The library started as the `components/ui` folders of Arbol's three dashboards. Most components keep the
apps' names and props, so moving a screen is usually changing an import from `~/components/ui/…` to
`@arbol-ai/ui`. This page lists what differs.
## Setup, once per app
1. `bun add @arbol-ai/ui` (React Aria, React and Tailwind are already there).
2. In the entry stylesheet, after Tailwind: `@import '@arbol-ai/ui/theme.css';`. Keep the app's own
`fonts.css` (the theme names Arbol Sans; it never ships font files). The theme does **not** ship the
apps' global `* { min-width: 0 }` or the unlayered `h1, h2 { font-family: Arbolito }` rule: keep them in
the app while its own screens depend on them.
3. Render `RouterUIProvider` from `@arbol-ai/ui/router` at the root (it is `UIProvider` wired to TanStack
Router: `navigate({ href })`, and base paths only for app paths, so `tel:` and `wa.me` links stay intact),
with the locale:
- Raíz and Account (Paraglide): `locale={getLocale() === 'es' ? 'es-CO' : 'en-US'}`.
- Agenda (Spanish only): `locale="es-CO"`. Agenda had no `I18nProvider`, so React Aria's own strings
(search clear button, drop zone…) followed the browser's language; this fixes it.
4. Mount ` ` once, inside `UIProvider`, to replace Raíz's sonner `Toaster`.
## Renamed props and changed defaults
| Component | Before (app) | Now |
| ------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Badge` | `variant="success"`, `"destructive-soft"`… | `tone` (`neutral`, `brand`, `success`, `warning`, `danger`, `info`) and `variant` (`soft`, `outline`); `variant="outline"` still works. JSDoc maps every old name. |
| `Tooltip` (Raíz) | `Tooltip` + `TooltipContent` | `TooltipTrigger` + `Tooltip` (React Aria's names). |
| `SaveBar` | `dirty`, `busy`, `label` | `isDirty`, `isPending`, `saveLabel`. |
| `Notice` | default tone `success` | default tone `info`: pass `tone="success"` where a bare `` meant success. |
| `ErrorState` | heading `h1` | heading `h2` by default; pass `level={1}` when it is the page. |
| `Select` (Raíz) | `value: string`, `onValueChange` | React Aria's `value` / `onChange`; label, description and error built in. |
| `PhoneField` (Raíz `PhoneInput`) | `onValueChange`, `disabled`, `invalid`, `emptyLabel` | `onChange`, `isDisabled`, `isInvalid`; the empty text is a library string. |
| `CursorPagination` (Raíz `ListPagination`) | `onPrev` | `onPrevious`. |
| `FilterToggle` (Agenda `InactiveFilter`) | `selected` | `isSelected`. |
| `UnsavedChanges` (Agenda) | imported TanStack Router | ` ` from `@arbol-ai/ui/router`. |
| `RowMenu` | navigated with `useNavigate` | uses `href`, routed by `UIProvider navigate`. |
| `Overlay` | `onClose` after a 200 ms timer | same API; `onClose` fires after the exit animation ends. Open it from a button with `OverlayTrigger`, not React Aria's `DialogTrigger`. |
| `ColorSwatchPicker` (Agenda `ColorPicker`) | oklch palette | pass the palette as hex (`#rrggbb`), `rgb()` or `hsl()`. |
| `Sheet` (Raíz) | non-modal | always modal; the assistant side panel stays app layout. |
| `ConfirmationDialog` (Raíz, two of them) | `labels` object, fixed DOM ids | one `ConfirmDialog` with plain props and an optional type-to-confirm value. |
| Toasts (Raíz, sonner) | `toast.error(message)` | the same call shape from `@arbol-ai/ui`. |
## Fixed on the way
- White text on the old destructive red (3.8:1) and on the 90%-opacity primary hover failed WCAG AA; both
now pass.
- Account's `SearchInput` ignored a controlled value on the first render; Agenda's could run a stale search.
- Account's `SettingToggleCard` nested a `` in a ``; Raíz's `EditableTitle` rendered
``.
- Raíz's breadcrumbs announced "breadcrumb" in English; DataTable rows had no keyboard path; nav landmarks had
no names.
- Agenda's pick list built plurals by concatenation ("1 seleccionado/s").
# Text and languages
> App copy is always yours; the library's own strings ship in Spanish and English and follow the provider's locale. Dates and numbers format in that locale.
Source: https://epidermis.getarbol.com/guides/text-and-languages/ · Markdown: https://epidermis.getarbol.com/guides/text-and-languages/index.md
There are exactly two kinds of text on a screen built with `@arbol-ai/ui`.
## App copy: always yours
Titles, labels, descriptions, column headers, button labels, empty-state sentences and item names come
through props and children, already translated by your app's own system (Paraglide's `m.*()`, i18next's
`t()`, or plain text). The library never defaults app copy.
```tsx
{m.contacts_title()}
```
## Library strings: built in, Spanish and English
What a component says by itself (a close button's name, "Previous page", "1–15 of 120", "No results",
screen-reader instructions, "Copied") ships in English (`en-US`) and Latin American Spanish (`es-419`) and
follows `UIProvider locale`: any `es-*` locale reads Spanish, everything else English. Numbers inside them
are formatted for the locale (`1.234` in `es-CO`).
Replace any of them, in whichever locale is active, with `UIProvider messages`. Values are strings or
functions of the message's variables:
```tsx
m.ui_pagination_range({ first, last, total }),
}}
>
```
A Paraglide message without variables plugs in directly; wrap one with variables, because the library
passes them as `Record`. Variables keep the names Arbol's catalogs use: `first`,
`last`, `total`, `count`, `page`, `label`, `value`. Several components also take a prop for one instance's
string (`saveLabel`, `retryLabel`, `cancelLabel`, `allLabel`, `emptyLabel`, `dismissLabel`).
## Dates, times and numbers
Components format with React Aria and `@internationalized/date` in the provider's locale. Spanish
conventions differ by region (`es-CO`: 12-hour clock, weeks start on Sunday; `es`: 24-hour, Monday), so
pick the locale tag deliberately, and pass `firstDayOfWeek` or `hourCycle` to the date components when the
product needs another convention. Pass numbers as numbers and dates as `CalendarDate` or `ZonedDateTime`
values, never pre-formatted strings, so plural rules and formats stay correct.
Every [component page](https://epidermis.getarbol.com/components/) has a language switch: its examples speak Spanish or English.
# Theming
> Every color, radius, shadow, font, size and width is a CSS variable in @arbol-ai/ui/theme.css. Dark mode, the mono appearance and fonts.
Source: https://epidermis.getarbol.com/guides/theming/ · Markdown: https://epidermis.getarbol.com/guides/theming/index.md
Every color, radius, shadow, font, size and width the components draw with is a CSS variable in
`@arbol-ai/ui/theme.css`: change one and every component, block and app that uses the library follows.
Components never carry a color or size of their own, so a theme change never misses one.
## Override a token
Set it after the import, for your app only:
```css title="app.css"
@import 'tailwindcss';
@import '@arbol-ai/ui/theme.css';
:root {
--app-shell-sidebar-width: 16rem;
--table-row-height-compact: 2.75rem;
}
```
Tailwind exposes the tokens as utilities through `@theme inline`: `bg-primary`, `text-muted-foreground`,
`border-border`, `rounded-field`, `shadow-md`, and the gray and status ramps (`bg-gray-a2`,
`bg-red-3 text-red-11 border-red-a3`).
## Dark mode and mono
- **Dark mode** is the `dark` class on ``.
- **The grayscale appearance** is `data-theme="mono"` on ``; `brand`, the default, is the
surgical teal.
Set both before the first paint, with an inline script in `index.html`, to avoid a flash. Every
[component page](https://epidermis.getarbol.com/components/) has a switch for the appearance, and its examples follow this site's dark
mode.
## Fonts
The theme names font families and never ships font files. Each stack tries the app's own faces first
(Arbol's licensed ones), then the brand's open faces, Schibsted Grotesk and Martian Mono (SIL Open Font
License, from Fontsource or Google Fonts), then the system's. Load your faces with `@font-face`, or
override the stacks:
```css
:root {
--ui-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--ui-font-heading: 'Inter', ui-sans-serif, system-ui, sans-serif;
--ui-font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```