Files
vps-tracker/.opencode/skills/reui/rules/components.md
T
Denozordec edf1da165a
Docker / build (push) Failing after 19s
feat(reui): update ReUI components and documentation to reflect 20 building blocks
- Expanded the ReUI skill description to include 20 free building blocks.
- Updated references in various documentation files to reflect the new count of components.
- Enhanced the data-grid documentation to specify the use of TanStack Table v9.
- Added new component details, including the icon-tile, and clarified usage instructions across multiple files.

This update improves clarity and ensures that all references are consistent with the latest component offerings.
2026-08-07 16:40:14 +07:00

14 KiB

ReUI components

The 20 ReUI building blocks: alert, autocomplete, badge, data-grid, date-selector, event-calendar, filters, frame, gantt, icon-stack, icon-tile, kanban, number-field, phone-input, rating, scrollspy, sortable, stepper, timeline, tree. Examples and blocks are composed from these.

Rule one: never guess a component's API. Read it first. Call get_component(name) for its inline api (props + usage, no web fetch), and share the result's docsUrl (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the /llms.txt index is a further fallback). Then call get_examples(name) to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline api is the full reference. No single block fits? Compose: search the components you need, read each get_component, install a get_examples example per component, and adapt.

data-grid (the flagship - read its API every time)

data-grid wraps TanStack Table v9. It is NOT a styled <table> and does NOT take data/columns props directly. The contract:

  • Build a TanStack table instance with useTable({ features: dataGridFeatures, ... }) (columns, data). dataGridFeatures is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
  • Pass that instance to <DataGrid table={table} recordCount={total}>.
  • Compose the body with DataGridTable inside DataGrid, and enable features through tableLayout (e.g. { headerSticky: true, columnsResizable: true }), not ad-hoc classes.
  • Server-side data uses the documented fetch shape (recordCount is the total for pagination).
const table = useTable({
  features: dataGridFeatures,
  data,
  columns,
})

<DataGrid table={table} recordCount={data.length}>
  <DataGridTable />
</DataGrid>

Common mistakes:

  • Incorrect: <DataGrid data={rows} columns={cols} /> - these props do not exist. Correct: build a useTable({ features: dataGridFeatures, ... }) instance and pass table={table} + recordCount.
  • Incorrect: a raw <table> / hand-rolled pagination. Correct: use data-grid; read its API for sticky header, pagination, virtualization, row selection.
  • Incorrect: styling rows/cells with arbitrary classes. Correct: drive layout via tableLayout and the primitive's DataGridColumnMeta (e.g. cellClassName, headerTitle), set through the bundle's columnMeta slot.

event-calendar

Required: events via events/onEventsChange (controlled) or defaultEvents (uncontrolled), plus a height on the root. Shape:

<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
  <EventCalendarNav />
  <EventCalendarContent />
</EventCalendar>

Gotcha: headless-first: EventCalendarContent renders the active view (month/week/day/days/agenda; a resource view activates when resources is passed) - there is no per-view JSX to compose. Events are { id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }. Mutations flow through onEventUpdate/canDropEvent (return false to reject); the root needs an explicit height because it is a min-h-0 flex column.

gantt

Required: resources (the left tree) plus bars via events/defaultEvents attached by resourceId. Shape:

<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
  <GanttNav />
  <GanttView />
</Gantt>

Gotcha: bars move along the time axis only (never across rows) and are all-day spans with exclusive end; progress is 0-100. Scales are day | week | month | quarter | year. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same onEventUpdate/canDropEvent commit pipeline as event-calendar; the root needs an explicit height.

kanban

Required: value (Record<string, T[]>), onValueChange, getItemValue Shape:

<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
  <KanbanBoard>
    {Object.entries(cols).map(([id, items]) => (
      <KanbanColumn key={id} value={id}>
        <KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
        <KanbanColumnContent value={id}>
          {items.map((i) => (
            <KanbanItem key={i.id} value={i.id}>
              <KanbanItemHandle>{i.title}</KanbanItemHandle>
            </KanbanItem>
          ))}
        </KanbanColumnContent>
      </KanbanColumn>
    ))}
  </KanbanBoard>
  <KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>

Gotcha: state is Record<columnId, T[]>. Each KanbanColumnContent value must match its parent KanbanColumn value. Omit KanbanOverlay and the drag preview silently breaks.

sortable

Required: value (T[]), onValueChange, getItemValue Shape:

<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
  {items.map((i) => (
    <SortableItem key={i.id} value={i.id}>
      <SortableItemHandle><GripVertical /></SortableItemHandle>
      {i.label}
    </SortableItem>
  ))}
</Sortable>

Gotcha: a flat 1D reorder list (not columns - that is kanban). getItemValue must return a stable, unique string. Pass layout="grid" or layout="nested" for non-list layouts.

filters

Required: filters (Filter[]), fields (FilterFieldConfig[]), onChange Shape:

const [filters, setFilters] = useState<Filter[]>([
  createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
  { key: "priority", label: "Priority", type: "multiselect",
    options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
]

<Filters filters={filters} fields={fields} onChange={setFilters} />

Gotcha: always build initial filters with createFilter(field, operator, values) - it generates the required id. Never hand-construct a Filter object. Pairs naturally with data-grid.

date-selector

Required: none, but wire onChange to capture the value. Shape:

const [value, setValue] = useState<DateSelectorValue | undefined>()

<DateSelector value={value} onChange={setValue} label="Due date" />

Gotcha: the value is a structured DateSelectorValue (period / operator / start+end dates), NOT a Date - never pass a raw Date. Use allowRange={false} to lock single-date picking. Read get_component("date-selector") for the value shape.

tree

Required: tree (a @headless-tree/core instance you construct) Shape:

<Tree tree={tree}>
  {tree.getItems().map((item) => (
    <TreeItem key={item.getId()} item={item}>
      <TreeItemLabel />
    </TreeItem>
  ))}
</Tree>

Gotcha: Tree is a styled shell - it takes a headless-tree instance via tree, NOT data/items props. Build the instance with @headless-tree/react. External API: https://headless-tree.lukasbach.com/

stepper

Required: StepperItem step (number), StepperContent value (number) Shape:

<Stepper defaultValue={1}>
  <StepperNav>
    <StepperItem step={1}>
      <StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
      <StepperSeparator />
    </StepperItem>
    <StepperItem step={2}>
      <StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
    </StepperItem>
  </StepperNav>
  <StepperPanel>
    <StepperContent value={1}>Step 1 content</StepperContent>
    <StepperContent value={2}>Step 2 content</StepperContent>
  </StepperPanel>
</Stepper>

Gotcha: steps are 1-indexed. Without StepperPanel + StepperContent you render the nav trail but no body. Put StepperSeparator in every StepperItem except the last.

timeline

Required: TimelineItem step (number) Shape:

<Timeline>
  <TimelineItem step={1}>
    <TimelineHeader>
      <TimelineDate>March 2024</TimelineDate>
      <TimelineTitle>Project initialized</TimelineTitle>
    </TimelineHeader>
    <TimelineIndicator />
    <TimelineSeparator />
    <TimelineContent>Repo and architecture set up.</TimelineContent>
  </TimelineItem>
</Timeline>

Gotcha: each item needs a unique step. orientation is "vertical" (default) or "horizontal". This is a static event display, not interactive like stepper.

autocomplete

Required: items (array; each item has at least value) Shape:

<Autocomplete items={items}>
  <AutocompleteInput placeholder="Search..." />
  <AutocompleteContent>
    <AutocompleteEmpty>No results found.</AutocompleteEmpty>
    <AutocompleteList>
      {(item) => (
        <AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
      )}
    </AutocompleteList>
  </AutocompleteContent>
</Autocomplete>

Gotcha: AutocompleteList takes a render-prop (item) => ReactNode, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete

phone-input

Required: none, but wire onChange. Shape:

<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />

Gotcha: value/onChange use an E.164 string (e.g. "+14155551234"), not a display-formatted string; onChange can fire undefined. defaultCountry is a 2-letter ISO code. Wraps react-phone-number-input.

number-field

Required: wrap the controls in NumberFieldGroup. Shape:

<NumberField defaultValue={0}>
  <NumberFieldScrubArea label="Quantity" />
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

Gotcha: import from @/components/ui/number-field. The accessible label goes on NumberFieldScrubArea, not NumberField. External API: https://base-ui.com/react/components/number-field

rating

Required: rating (number) Shape:

<Rating rating={4.5} showValue editable onRatingChange={setRating} />

Gotcha: supports decimals (partial stars). Pass editable + onRatingChange for interactive input; omit both for a read-only display.

scrollspy

Required: targetRef (the scroll container ref) Shape:

<Scrollspy targetRef={containerRef}>
  <a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
  <a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
  <div id="s1">...</div>
  <div id="s2">...</div>
</div>

Gotcha: each link's data-scrollspy-anchor must match a section id. targetRef is the scrollable container (defaults to the window).

frame

Required: Frame > FramePanel Shape:

<Frame>
  <FramePanel>
    <FrameHeader>
      <FrameTitle>Title</FrameTitle>
      <FrameDescription>Description</FrameDescription>
    </FrameHeader>
    <div className="p-5">Content</div>
    <FrameFooter>Footer</FrameFooter>
  </FramePanel>
</Frame>

Gotcha: a structured card shell for tool-like surfaces. stacked connects multiple panels with shared borders; dense removes panel padding; radius via the --frame-radius CSS variable.

icon-stack

Required: one child icon Shape:

<IconStack aria-hidden="true">
  <InboxIcon className="size-4" />
</IconStack>

Gotcha: isometric layered artwork for empty states and illustrations; style the inner icon via its own className. Mark purely decorative stacks aria-hidden="true" and keep the real label in surrounding copy.

icon-tile

Required: one child icon Shape:

<IconTile variant="elevated" size="lg">
  <PackageIcon />
</IconTile>

Gotcha: the square container an icon sits in, so every list row, feature card and empty state shares one affordance. variant: outline (default) | elevated (muted fill, raised ring) | soft (tinted nested, tone from currentColor) | solid (filled tone, contrasting glyph) | frame (double container). soft and solid retint from one text color class (they default to text-primary). size: xs | sm | default | lg | xl (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). radius: default | full. Do not set a size-* class on the child icon unless you mean to override the tile's glyph size; recolor with className on the tile, not the icon.

alert

Required: Alert > AlertTitle Shape:

<Alert variant="success">
  <ShieldCheckIcon />
  <AlertTitle>Security update</AlertTitle>
  <AlertDescription>Enable two-factor authentication.</AlertDescription>
  <AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>

Gotcha: shadcn-compatible API. variant: default | destructive | info | success | warning | invert. The non-default variants use ReUI extended color tokens (--success/--info/--warning/--invert), which the install adds. Defer generic alert rules to the shadcn skill.

badge

Required: none (text child). Shape:

<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>

Gotcha: shadcn-compatible. Rich variant set (solid, -outline, -light per color), size xs..xl, radius default | full. Like alert, the color variants rely on ReUI extended tokens. Prefer Badge variants over raw color classes for statuses.

base vs radix - write for the project's base

ReUI ships every component in two builds: base (Base UI) and radix (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the right base, because their APIs differ.

Detect the base first. Read components.json -> style and take the segment before the first -:

  • "style": "base-nova" -> Base UI
  • "style": "radix-nova" -> Radix UI

Then use that base's API. The deltas mirror shadcn's base-vs-radix split:

  • Slot/composition: Base UI render={<… />} vs Radix asChild.
  • Select: Base UI takes items; Radix uses <SelectItem> children.
  • ToggleGroup: Base UI multiple boolean vs Radix type="single" | "multiple".

The safest path is to read the installed files and c-* examples - they're already in your base, so reuse their wiring instead of guessing. When get_component's inline api or an example shows the other base's shape, translate it to your base (or validate_usage to confirm). Defer the generic base/radix mechanics to the shadcn skill.