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:
| Component | Built on | Notes |
|---|---|---|
Button | ark.button | variant solid or outline, size sm or md |
Input | Ark Field.Input | Same size and height as Button |
Select | Ark Select | Single value, options array, list in a portal |
Textarea | Ark Field.Textarea | Multi-line text, rows (4), resizable in height; inside a FormField |
FormField | Ark Field | Label, hint, error and required marker for one Input, Textarea or Select |
Checkbox | Ark Checkbox | Label as children, helperText, error, indeterminate |
RadioGroup | Ark Fieldset + RadioGroup | label (the question), options with an optional description, orientation, error |
Switch | Ark Switch | A setting that applies at once; label as children, helperText |
Card | ark.div | CardHeader, CardTitle (an <h2>), CardDescription, CardBody, CardFooter |
Table | ark.table | TableHead, TableBody, TableRow, TableHeader, TableCell, numeric |
Badge | ark.span | tone neutral, success, warning, danger |
Dialog | Ark Dialog | DialogTrigger, DialogContent, DialogFooter, DialogClose |
Alert | ark.div | A message that stays: tone, title, children; role="alert" for danger and warning |
Toaster + toast() | Ark Toast | One <Toaster /> at the root; toast({ title, description, tone }) from anywhere |
Tooltip | Ark Tooltip | content and one trigger as children; describes, does not name |
Spinner | ark.output | A live region with a label (hidden unless showLabel), size sm or md |
Skeleton | ark.div | lines of text or a block (size sm or md); hidden from screen readers |
Link | ark.a | href, external (new tab, said to screen readers), variant (underline in text, plain in tables and lists); routes through RouterProvider |
Breadcrumb | nav + Link | items, the current page last (aria-current="page") |
Pagination | nav + links or buttons | page, count, and href(page) or onPageChange; numbers give way to "Page 2 of 9" on a phone |
Tabs | Ark Tabs | items with value, label, content; an aria-label |
Heading | ark.h1–h4 | level (the outline) apart from size (the look) |
Text | ark.p / span | size, tone, weight, numeric, mono; a span inherits what it does not set |
FormatNumber, FormatDate | Intl, Ark's locale | Raw value in, the locale's way out; LocaleProvider sets the locale (default en-US); dates in UTC |
Stat | dl | label, value, change with a changeTone that says good or bad news, description |
DataList | dl | items of label and value, horizontal or vertical |
EmptyState | div | title, description, actions as children |
Progress, ProgressCircle | Ark Progress | label, value (or null), max, valueText in words, tone |
Avatar | Ark Avatar | name (initials and accessible name), src, size |
IconButton, CloseButton | ark.button | aria-label required by the type; ghost, outline, solid |
Menu | Ark Menu | Governed parts: MenuTrigger, MenuContent, MenuItem (href, tone), MenuGroup (label), MenuSeparator; size on the root reaches them all |
Accordion | Ark Accordion | Governed parts: AccordionItem, AccordionTrigger (level of its heading), AccordionContent; multiple, size |
Collapsible | Ark Collapsible | One section: label for the button, content as children |
Popover | Ark Popover | PopoverTrigger, PopoverContent with a required title; not modal |
Drawer | Ark Dialog | DrawerTrigger, DrawerContent (title, placement start or end, size), DrawerFooter, DrawerClose |
Clipboard | Ark Clipboard | value, label; showValue={false} for the button alone, named "Copy" and the label |
Portal | Ark Portal | Children rendered at the end of the page, inside a .vektos root |
NumberInput | Ark NumberInput | value as a number or null; format and currency like FormatNumber, fractionDigits, min, max, step; showSteppers={false} for amounts |
DatePicker | Ark DatePicker | Values in and out as YYYY-MM-DD; day, month and year views; min, max |
Combobox | Ark Combobox | options filtered as the user types; value or null; emptyText |
SegmentGroup | Ark SegmentGroup | Two to five options side by side, applied at once; aria-label required |
Steps | Ark Steps | items with title, description, content; step; onStepChange lets a completed step be pressed to go back |
FileUpload | Ark FileUpload | label, hint, accept, maxFiles, maxFileSize; the drop area is the button; a refused file says why |
Stack | ark.div | A column (default) or a row: flexDirection, gap, alignItems, justifyContent, flexWrap |
Grid | ark.div | gridTemplateColumns, 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,gridRowand their start/end; - position:
position,zIndex,insetand 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:
| Reader | What 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 grader | Counts 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.
globalCssstyles.vektos :focus-visiblewithaccent.text. Components never write their own, so an agent has nothing to forget. - Invalid comes from
FormField. Panda's preset matches the native:invalidin_invalid, which turns every emptyrequiredinput 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 avektosdiv, so reset, colors and focus ring still apply. - Select takes an
optionsarray, not compound children. One prop is harder to misuse than seven Ark parts, and it maps to<select>in an agent's head.RadioGroupdoes the same. - Form controls have their own small API.
Checkbox,SwitchandRadioGroupdeclare 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.
CheckboxandSwitchtake it as children,RadioGroupaslabel(a<legend>, through Ark'sFieldset). None goes inside aFormField; hint and error are props, wired witharia-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.textborder, and the tick isaccent.fgon the fill. The roles of chapter 34 hold: noaccent.textfill. - 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 namedcontrolanditem-control, so components still draw no ring of their own. - Feedback speaks the same tones.
Alertandtoast()taketonelikeBadge:neutral,success,warning,danger. Each tone has its own icon shape too, so it is not told by colour only. Adangertoast 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.tsxis a client module. Its toaster is created when the module loads, and Ark'screateToasterruns only in the browser. Without"use client", importing anything from@vektos/dsin a server component broke every page (anecdote 15).- Links know the router without knowing Next.
RouterProvidertakes anavigate(and aprefetch) 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 theLinkcomponent, and the grader counts a native<a>. A design system that styles bare elements makes the component optional. Paginationworks 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 anhrefin 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,DataListand 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.
FormatNumbertakes-1830and 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 isformat, because a governed component takes nostyle, 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.mutedoff,accent.fgon), 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 writetextAlignby hand. TableHeadis ink. Dark in both themes, with thedarkclass, so header text and links inside take their dark-theme colors (guide 01).Cardhasaccent. A mint line along the top edge marks the one card that matters most in a row, like the run count on the overview.CardHeaderkeeps its own gap below. A table can follow the header directly, and aCardBodyafter a header takes the difference off its top, so no page needs a margin there.- Layout defaults are not in the recipe.
InputandTableare full width through acss.raw({ width: "full" })passed next to the layout props, so a page'swidthreplaces 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
classNamethe 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:6006Open 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
- Write
src/<name>.tsx: an exportedinterface <Name>Propswith one JSDoc line per prop, acvaorsvarecipe, and a function component with a JSDoc description. The interface extendsGoverned<ComponentProps<…>>andEscapeHatch, plusLayoutPropsif a page places it (a root, not a part). In the function, takeUNSAFE_classNameandUNSAFE_style, split the rest withsplitLayout, and build the class withgovernedClass(recipe, layout, UNSAFE_className, defaults?). Keep layout (width, margins) out of the recipe: pass it asdefaults. - Use semantic color tokens only (
no-primitive-tokenfails otherwise). If a spacing, size or radius is missing, add it topanda.config.ts, not a raw value. - If it opens in a portal, wrap the portal content in
<div className="vektos">. - Write
src/<name>.stories.tsx. Compound components list their parts insubcomponentsso the docs page shows a props tab for each. Write layout values in the JSX (<Stack gap="4">), not inargs: Panda reads them where they are written. - Export it from
src/index.ts, runbun 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 onDialogis enough, and does not move. Menuhas no checkbox or radio items and no sub-menus yet; Ark has them.DatePickerpicks one date; no ranges.Comboboxpicks one value; no multiple selection, no creating a value that is not in the list.Stepsdoes not stack itself on a narrow screen: passorientation="vertical"there.- No danger button. A destructive action uses a solid button and a confirmation dialog.
A
dangervariant needs adanger.fgtoken that meets contrast in both themes. Tabledoes not scroll horizontally on narrow screens; wrap it if a page needs that.Selectis single-value only and not searchable.- No
CheckboxGroup: several checkboxes under one question are aStackofCheckboxes, without a<legend>. Ark has the group; add it when a task needs one. - Nothing counts a hand-written
<h1>or<p>withcss()whereHeadingorTextwould do; that rule would fire on every page, so it waits for data. ProgressCirclesizes its ring through--sizeand--thickness, set inpanda.config.ts.Switchhas noerror: a setting that applies at once has nothing to validate.- Toasts have no action button ("Undo"), and no animation: they appear and go.
Tooltiptakes one trigger that accepts a ref; our components do (React 19 passesrefas 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/linkdoes; only on hover and focus. next/linkused without the design system'sLinkis 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 checkyet: that needs the Storybook Vitest addon, which we skipped forbun test. - The recipes are not exported, so a link cannot borrow the button style. Add
asChildusage 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}>withgapfrom a parameter, or a story'sargs, renders without the style, with no error and no violation (anecdote 14). Selecttakes layout props on its root, not on the trigger;DialogContenttakes none (itssizesets the width).- A
divwithdisplay: flexin a page is not counted, althoughStackexists. Whether it should be is an open decision.