# 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';
``` ## 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';
``` ## 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 ( ); }; ``` ## 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 (
undefined}> }> Profile
); }; ``` ## 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 ( <> { 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 ( <> {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 ( <> {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 ( <> {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 ( ( )} 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={ 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 (
} title="New payer"> {(_close, leave) => (
{ event.preventDefault(); event.stopPropagation(); setAdded(kind); leave(); }} >