Skip to Content
Vektos
Guides

Components

The design-system components, how they are built, and the governed model that keeps pages from restyling them.

What

@vektos/ds has forty-six components, the ones a fintech dashboard and its forms need, and two layout primitives:

ComponentBuilt onNotes
Buttonark.buttonvariant solid or outline, size sm or md
InputArk Field.InputSame size and height as Button
SelectArk SelectSingle value, options array, list in a portal
TextareaArk Field.TextareaMulti-line text, rows (4), resizable in height; inside a FormField
FormFieldArk FieldLabel, hint, error and required marker for one Input, Textarea or Select
CheckboxArk CheckboxLabel as children, helperText, error, indeterminate
RadioGroupArk Fieldset + RadioGrouplabel (the question), options with an optional description, orientation, error
SwitchArk SwitchA setting that applies at once; label as children, helperText
Cardark.divCardHeader, CardTitle (an <h2>), CardDescription, CardBody, CardFooter
Tableark.tableTableHead, TableBody, TableRow, TableHeader, TableCell, numeric
Badgeark.spantone neutral, success, warning, danger
DialogArk DialogDialogTrigger, DialogContent, DialogFooter, DialogClose
Alertark.divA message that stays: tone, title, children; role="alert" for danger and warning
Toaster + toast()Ark ToastOne <Toaster /> at the root; toast({ title, description, tone }) from anywhere
TooltipArk Tooltipcontent and one trigger as children; describes, does not name
Spinnerark.outputA live region with a label (hidden unless showLabel), size sm or md
Skeletonark.divlines of text or a block (size sm or md); hidden from screen readers
Linkark.ahref, external (new tab, said to screen readers), variant (underline in text, plain in tables and lists); routes through RouterProvider
Breadcrumbnav + Linkitems, the current page last (aria-current="page")
Paginationnav + links or buttonspage, count, and href(page) or onPageChange; numbers give way to "Page 2 of 9" on a phone
TabsArk Tabsitems with value, label, content; an aria-label
Headingark.h1–h4level (the outline) apart from size (the look)
Textark.p / spansize, tone, weight, numeric, mono; a span inherits what it does not set
FormatNumber, FormatDateIntl, Ark's localeRaw value in, the locale's way out; LocaleProvider sets the locale (default en-US); dates in UTC
Statdllabel, value, change with a changeTone that says good or bad news, description
DataListdlitems of label and value, horizontal or vertical
EmptyStatedivtitle, description, actions as children
Progress, ProgressCircleArk Progresslabel, value (or null), max, valueText in words, tone
AvatarArk Avatarname (initials and accessible name), src, size
IconButton, CloseButtonark.buttonaria-label required by the type; ghost, outline, solid
MenuArk MenuGoverned parts: MenuTrigger, MenuContent, MenuItem (href, tone), MenuGroup (label), MenuSeparator; size on the root reaches them all
AccordionArk AccordionGoverned parts: AccordionItem, AccordionTrigger (level of its heading), AccordionContent; multiple, size
CollapsibleArk CollapsibleOne section: label for the button, content as children
PopoverArk PopoverPopoverTrigger, PopoverContent with a required title; not modal
DrawerArk DialogDrawerTrigger, DrawerContent (title, placement start or end, size), DrawerFooter, DrawerClose
ClipboardArk Clipboardvalue, label; showValue={false} for the button alone, named "Copy" and the label
PortalArk PortalChildren rendered at the end of the page, inside a .vektos root
NumberInputArk NumberInputvalue as a number or null; format and currency like FormatNumber, fractionDigits, min, max, step; showSteppers={false} for amounts
DatePickerArk DatePickerValues in and out as YYYY-MM-DD; day, month and year views; min, max
ComboboxArk Comboboxoptions filtered as the user types; value or null; emptyText
SegmentGroupArk SegmentGroupTwo to five options side by side, applied at once; aria-label required
StepsArk Stepsitems with title, description, content; step; onStepChange lets a completed step be pressed to go back
FileUploadArk FileUploadlabel, hint, accept, maxFiles, maxFileSize; the drop area is the button; a refused file says why
Stackark.divA column (default) or a row: flexDirection, gap, alignItems, justifyContent, flexWrap
Gridark.divgridTemplateColumns, gridTemplateRows, gap, alignItems, justifyItems

Each lives in packages/ds/src/<name>.tsx with its stories next to it. Styles are a Panda recipe in the same file: cva for one element, sva for several parts. Recipe variants mirror the props one to one.

Governed: the component owns how it looks

Like Adobe Spectrum 2, the components take no className and no style. A page passes their own props (variant, tone, size, accent) and layout props, which say where the component sits and never how it looks:

  • margins (margin, marginBlockStart, marginInlineStart…), width, minWidth, maxWidth;
  • a place in a flex or grid container: flexGrow, flexShrink, flexBasis, alignSelf, justifySelf, order, gridArea, gridColumn, gridRow and their start/end;
  • position: position, zIndex, inset and its logical parts; visibility, hideFrom, hideBelow.

Long, logical names only (no ms, no marginLeft), tokens only, responsive objects allowed. No height: a component is as tall as its content. Parts (CardBody, TableCell, DialogFooter) take no layout props: their parent places them.

<Stack flexDirection={{ base: "column", md: "row" }} gap="4">
  <Card flexGrow={1}>…</Card>
  <Badge tone="warning" marginInlineStart="2">Pending</Badge>
</Stack>

The list lives once, in packages/ds/src/layout-props.ts, and three places read it:

ReaderWhat it does with a prop that is not on the list
The types (LayoutProps, Governed<P> in layout.ts)TS2322: the prop does not exist
Panda's jsxMatchTag (panda.config.ts)Generates no CSS for it, even past the types
The benchmark's outside-styling graderCounts it as a design-system violation

Governed parts: Menu and Accordion

Most compound components take an array (Select's options, Tabs' items). A menu item or an accordion section holds free content, so those two have parts. They are built with Panda's createSlotRecipeContext (styled-system/jsx): a size set once on the root reaches every part through a context, and each part is one line. The helper wraps each part in styled(), which reads style props; the exported types do not offer them. GovernedPart<P> in layout.ts also drops the HTML attributes that share a style prop's name (color, translate, content): TypeScript found the first two when Menu was written.

The way out is named so nobody takes it by accident: UNSAFE_className and UNSAFE_style. They work, they are hidden from the MCP's component help, and the grader counts every use; vektos-ignore cannot silence them.

Why

These are the pieces the benchmark tasks ask for (a transactions table, a payment form, a confirmation dialog). The fewer and clearer they are, the easier it is to tell an agent that misused the system from a system that left a gap.

A few choices exist only to remove ways of getting it wrong:

  • One focus ring for everything. globalCss styles .vektos :focus-visible with accent.text. Components never write their own, so an agent has nothing to forget.
  • Invalid comes from FormField. Panda's preset matches the native :invalid in _invalid, which turns every empty required input red on load. The config redefines the condition to [data-invalid] and [aria-invalid=true] only. The way to show an error is <FormField error="…">.
  • Portals bring .vektos. The Select list and the Dialog render at the end of <body>, outside the app's root. Each wraps its portal content in a vektos div, so reset, colors and focus ring still apply.
  • Select takes an options array, not compound children. One prop is harder to misuse than seven Ark parts, and it maps to <select> in an agent's head. RadioGroup does the same.
  • Form controls have their own small API. Checkbox, Switch and RadioGroup declare every prop (checked, onCheckedChange(checked: boolean), value, onValueChange(value)) instead of passing Ark's through: Ark calls back with a details object and allows "indeterminate" as a checked state, two things an agent gets wrong. The MCP says so: "No other props".
  • Each control brings its own label. Checkbox and Switch take it as children, RadioGroup as label (a <legend>, through Ark's Fieldset). None goes inside a FormField; hint and error are props, wired with aria-describedby.
  • Checked is mint with an emerald edge. The mint fill alone is too light to see the box on a page (WCAG 1.4.11 asks 3:1 for a control's boundary), so a checked control has an accent.text border, and the tick is accent.fg on the fill. The roles of chapter 34 hold: no accent.text fill.
  • The same focus ring on a drawn control. A checkbox focuses a hidden input; Ark marks the drawn box data-focus-visible. The global rule covers [data-focus-visible] on the parts named control and item-control, so components still draw no ring of their own.
  • Feedback speaks the same tones. Alert and toast() take tone like Badge: neutral, success, warning, danger. Each tone has its own icon shape too, so it is not told by colour only. A danger toast stays until closed; the others go after five seconds.
  • The tooltip is an ink region, dark in both themes like the header, with a thin border so it stays visible on a dark page.
  • toast.tsx is a client module. Its toaster is created when the module loads, and Ark's createToaster runs only in the browser. Without "use client", importing anything from @vektos/ds in a server component broke every page (anecdote 15).
  • Links know the router without knowing Next. RouterProvider takes a navigate (and a prefetch) function, like Spectrum's. A link stays a real <a href>, so opening it in a new tab still works; a plain click on an internal path goes through the router. Without a provider, links are plain links. Only paths that start with one / are routed.
  • No global link style. The rule that underlined every <a> without a class is gone: a link is the Link component, and the grader counts a native <a>. A design system that styles bare elements makes the component optional.
  • Pagination works in a server component. It has no hooks: it computes each address on the server and hands a small client anchor a string. A function prop could not cross to the client. Which pages to show is a pure function (internal/page-range.ts, tested); Ark's pagination renders buttons with an href in link mode, so it was not used.
  • Content components exist for pages and for generated UI. A JSON spec cannot write <h1> or <p>; Heading, Text, Stat, DataList and the format components are what a catalog needs (the generative UI blocks of the plan), and what an agent writing code should use too.
  • Numbers are formatted by the design system. FormatNumber takes -1830 and a currency, not "−€1,830.00": the page cannot get a locale wrong, and a model in the chat cannot either.
  • No prop is called style. FormatNumber's Intl option is format, because a governed component takes no style, and the grader counts one as styling from outside.
  • changeTone, not a direction. A cost going up is bad news; the tone says what the arrow cannot.
  • The switch's thumb is an icon. Its colour follows the state as a text colour (fg.muted off, accent.fg on), and it moves by alignment, not a transform.
  • Table cells have numeric. Amounts align to the end with tabular figures, so an agent has no reason to write textAlign by hand.
  • TableHead is ink. Dark in both themes, with the dark class, so header text and links inside take their dark-theme colors (guide 01).
  • Card has accent. A mint line along the top edge marks the one card that matters most in a row, like the run count on the overview.
  • CardHeader keeps its own gap below. A table can follow the header directly, and a CardBody after a header takes the difference off its top, so no page needs a margin there.
  • Layout defaults are not in the recipe. Input and Table are full width through a css.raw({ width: "full" }) passed next to the layout props, so a page's width replaces it. In the recipe, Panda's atomic classes would decide by stylesheet order (anecdote 12).
  • Why governed. A page that restyles a component makes the design system a suggestion, and a className the recipe silently beats is worse: nobody sees it fail. Refusing it makes the rule visible to the compiler, to Panda and to the benchmark at once.

Try it

bun run storybook   # http://localhost:6006

Open any story and switch the theme from the toolbar. In code:

import { Button, FormField, Input, Select } from "@vektos/ds";

<FormField label="Amount" error={amount <= 0 ? "Enter an amount greater than zero." : undefined}>
  <Input inputMode="decimal" />
</FormField>
<FormField label="Currency">
  <Select options={[{ label: "Euro (EUR)", value: "EUR" }]} defaultValue="EUR" />
</FormField>
<Button>Send transfer</Button>

To check accessibility from outside Storybook, inject axe into a story page and run axe.run(document). Use #storybook-root in selectors: the iframe also contains a hidden skeleton with its own <button>.

Extend it

  1. Write src/<name>.tsx: an exported interface <Name>Props with one JSDoc line per prop, a cva or sva recipe, and a function component with a JSDoc description. The interface extends Governed<ComponentProps<…>> and EscapeHatch, plus LayoutProps if a page places it (a root, not a part). In the function, take UNSAFE_className and UNSAFE_style, split the rest with splitLayout, and build the class with governedClass(recipe, layout, UNSAFE_className, defaults?). Keep layout (width, margins) out of the recipe: pass it as defaults.
  2. Use semantic color tokens only (no-primitive-token fails otherwise). If a spacing, size or radius is missing, add it to panda.config.ts, not a raw value.
  3. If it opens in a portal, wrap the portal content in <div className="vektos">.
  4. Write src/<name>.stories.tsx. Compound components list their parts in subcomponents so the docs page shows a props tab for each. Write layout values in the JSX (<Stack gap="4">), not in args: Panda reads them where they are written.
  5. Export it from src/index.ts, run bun run check, and check both themes with axe.

Known gaps

  • No animations: Dialog, Drawer, Menu, Popover and Select appear and disappear instantly. Ark's own Drawer (a sheet with swipe and snap points) is not used: a side panel built on Dialog is enough, and does not move.
  • Menu has no checkbox or radio items and no sub-menus yet; Ark has them.
  • DatePicker picks one date; no ranges. Combobox picks one value; no multiple selection, no creating a value that is not in the list.
  • Steps does not stack itself on a narrow screen: pass orientation="vertical" there.
  • No danger button. A destructive action uses a solid button and a confirmation dialog. A danger variant needs a danger.fg token that meets contrast in both themes.
  • Table does not scroll horizontally on narrow screens; wrap it if a page needs that.
  • Select is single-value only and not searchable.
  • No CheckboxGroup: several checkboxes under one question are a Stack of Checkboxes, without a <legend>. Ark has the group; add it when a task needs one.
  • Nothing counts a hand-written <h1> or <p> with css() where Heading or Text would do; that rule would fire on every page, so it waits for data.
  • ProgressCircle sizes its ring through --size and --thickness, set in panda.config.ts.
  • Switch has no error: a setting that applies at once has nothing to validate.
  • Toasts have no action button ("Undo"), and no animation: they appear and go.
  • Tooltip takes one trigger that accepts a ref; our components do (React 19 passes ref as a prop), a custom component that drops it will not show the tooltip.
  • Routed links do not prefetch when they scroll into view, as next/link does; only on hover and focus.
  • next/link used without the design system's Link is not counted: it is not a native <a> in the source, and the reset leaves it unstyled.
  • Axe runs in the Storybook panel and by hand. It does not run in bun run check yet: that needs the Storybook Vitest addon, which we skipped for bun test.
  • The recipes are not exported, so a link cannot borrow the button style. Add asChild usage or export the recipe when a task needs it.
  • A layout value known only at run time has no CSS. Panda reads literals, local constants, both branches of a ternary and object literals in the JSX. <Stack gap={gap}> with gap from a parameter, or a story's args, renders without the style, with no error and no violation (anecdote 14).
  • Select takes layout props on its root, not on the trigger; DialogContent takes none (its size sets the width).
  • A div with display: flex in a page is not counted, although Stack exists. Whether it should be is an open decision.

On this page