Data display

Containers and readouts: the card every panel is built from, and the components that put numbers, rows and progress in front of someone who has to make a decision from them.

Card

The container everything else sits in. Give it a title and it grows a header; fill the footer slot and it grows a footer. The header slot wins over the title prop, so a custom header never means clearing a prop first. Set padding="none" for content that should reach the edges, like a table.

Header, body, footer

Sales

This month

Rp 42.500.000 across 318 tickets.

Updated 5 minutes ago

A bare card — no header, no footer, raises on hover.

<UiCard title="Sales" subtitle="This month">
PropTypeDefaultDescription
titlestringundefinedHeading in the card header.
subtitlestringundefinedMuted line below the title.
padding'none' | 'sm' | 'md' | 'lg''md'Body padding. Use none for tables and charts that reach the edge.
borderedbooleantrueOuter border.
hoverablebooleanfalseRaises the shadow on hover — for cards that are clickable.

DataTable

A toolbar layer on top of Table: a search box, select filters, and CSV export. Sort/page/selection state still lives in Table — DataTable doesn't copy it. Two deliberate decisions: export always takes the rows currently visible after filtering, not the whole dataset — silently exporting the entire database is a leak, not a feature — and for every filter, the value '' means "all", not "the empty one".

Search, filter, export, and select rows

Code
AB-1201
Passenger
Budi Santoso
Status
paid
Code
AB-1202
Passenger
Ani Rahmawati
Status
pending
Code
AB-1203
Passenger
Citra Dewi
Status
cancelled
Code
AB-1204
Passenger
Dedi Kurniawan
Status
paid
Code
AB-1205
Passenger
Eka Putri
Status
pending

Selected: none yet. Filter to "Cancelled" then press Export — only the visible rows are sent to @export and to the CSV.

<UiDataTable :columns :rows row-key="id" :filters :search-keys="['code', 'passenger']" exportable selectable />

Manual mode — server does the filtering

Code
AB-1201
Passenger
Budi Santoso
Status
paid
Code
AB-1202
Passenger
Ani Rahmawati
Status
pending
Code
AB-1203
Passenger
Citra Dewi
Status
cancelled
Code
AB-1204
Passenger
Dedi Kurniawan
Status
paid
Code
AB-1205
Passenger
Eka Putri
Status
pending

In manual mode, DataTable doesn't filter anything itself — it only emits v-model:search and @filter-change so the caller can send them to the API and fill :rows/:total from the server's response. Search box above: "(empty)".

<UiDataTable manual :total="total" @update:search @filter-change />
PropTypeDefaultDescription
columns / rows / rowKeyTableColumn[] / T[] / string | fnSame as Table — passed through as-is.
filtersDataTableFilter[]undefined{ key, label, options, allLabel? }. One native <select> per filter.
searchKeysstring[][]Columns included in Table's free-text search.
searchPlaceholder / searchLabelstring'Search…' / 'Search table'searchLabel becomes the aria-label of the search box.
exportable / exportFileNameboolean / stringfalse / 'export.csv'Shows the Export button and its download file name.
@export(rows: T[]) => voidThe rows CURRENTLY VISIBLE after filtering — not the entire props.rows.
manual + totalboolean + numberfalseThe component stops filtering on its own; it only emits v-model:search & @filter-change for the caller to send to the API.
@filter-change(values: FilterValues) => voidEmitted every time a filter changes, including back to "all".
selectable / expandable / paginate / …booleansee TablePassed straight through to the Table underneath, along with its five v-models.

List

A ranked list for dashboard widgets: best-selling routes, agents with the highest commission, anything that needs sorting and quick comparison. The proportion bar is measured against the largest entry, not against the total of all entries — if measured against the total, ten balanced entries would each show a tiny 10% bar that's arithmetically correct but useless to look at. The top row is always full.

Best-selling routes

  1. 1

    Jakarta — Surabaya

    214
  2. 2

    Bandung — Yogyakarta

    156
  3. 3

    Jakarta — Semarang

    98
  4. 4

    Surabaya — Malang

    12
<UiList :items="rutes" show-rank />

Agents with avatar and formatted value

<UiList :items="agen" show-avatar :format-value="formatMoney" clickable @select="..." />

Empty

No sales yet this month

<UiList :items="[]" empty-text="No data yet" />
PropTypeDefaultDescription
itemsListItem[]Required. { id, label, value, sublabel?, src?, icon?, tone? }
showBarbooleantrueProportion bar under the label.
showRankbooleanfalseRank number on the left of each row.
showAvatarbooleanfalseAvatar from name/src replaces the icon.
clickablebooleanfalseWraps the row in a <button>, emits @select.
densebooleanfalseTighter spacing between rows.
emptyTextstring'Nothing to show'Text shown when items is empty.
formatValue(value: number) => stringundefinedWithout this, value is rendered as-is — it's not the component's job to guess currency or unit.
@select(item: ListItem) => voidOnly emitted when clickable.

Rating

Stars that are either read-only (supports half stars) or fillable (whole values only). Half stars are deliberately limited to readonly mode — a mouse can't honestly click half a star, so interactive mode only offers whole choices from one up to max.

Fillable

3
Your rating: 3
<UiRating v-model="nilai" show-value clearable />

Readonly summary

(212)
<UiRating :model-value="4.5" readonly :count="212" />

Disabled

<UiRating :model-value="2" disabled />
PropTypeDefaultDescription
modelValuenumber0v-model. Whole number for interactive mode, decimals allowed (e.g. 3.5) for readonly.
maxnumber5Number of stars.
readonlybooleanfalseDisplay only, supports half stars. Not a control.
disabledbooleanfalseStill a control, but ignores clicks/keyboard.
clearablebooleanfalseClicking an already-active star clears the value to 0.
sizenumber20Star icon size (px).
countnumberundefinedReview count, shown next to the stars when set.
labelstring'Rating'aria-label for the radio group in interactive mode.
showValuebooleanfalseShows the numeric value next to the stars.

StatCard

One number, its label, and how it moved. Two details it refuses to guess: a delta of 0 renders flat rather than green, and whether up is good is declared with invertDelta — cancellations rising 12% is not good news, and no component can infer that from a metric's name.

A dashboard row

Tickets sold

318

+12%vs last week

Revenue

Rp 42,5jt

+4%vs last week

Cancellations

7

+12%vs last week

Occupancy

86%

0%unchanged

Loading

Buses on the road

12

3 arriving within the hour.
<UiStatCard label="Tickets sold" :value="318" :delta="12" />
PropTypeDefaultDescription
labelstringRequired. What the number counts.
valuestring | numberundefinedThe number itself, already formatted.
deltanumberundefinedPercent change. 0 renders as flat, not up.
deltaLabelstringundefinedWhat the change is measured against.
iconstringundefinedDecorative icon in the tinted square.
tone'primary' | 'success' | 'warning' | 'danger' | 'info''primary'Icon square colour.
invertDeltabooleanfalseUp is bad. Use for cancellations, refunds, complaints.
loadingbooleanfalseShows a placeholder instead of the value.

Table

All of the state lives in useDataTable, so the rules that are easy to get wrong are settled once and tested without a DOM: empty cells sink to the bottom in both sort directions, select-all touches only the rows that survived the filter, selection is stored by row key rather than index, and the page clamps itself when a filter shrinks the data instead of leaving you staring at an empty table.

Sortable, selectable, expandable

Code
AB-1201
Passenger
Budi Santoso
Seats
2
Status
Paid
Code
AB-1202
Passenger
Ani Rahmawati
Seats
10
Status
Pending
Code
AB-1203
Passenger
Citra Dewi
Seats
Status
Cancelled

Selected: nothing yet. Sort the Seats column twice — the cancelled booking with no seat stays at the bottom either way.

<UiTable :columns="columns" :rows="rows" row-key="id" selectable expandable paginate />

Sticky header

Code
AB-1201
Passenger
Budi Santoso
Seats
2
Status
Paid
Code
AB-1202
Passenger
Ani Rahmawati
Seats
10
Status
Pending
Code
AB-1203
Passenger
Citra Dewi
Seats
Status
Cancelled
Code
AB-1204
Passenger
dedi Kurniawan
Seats
1
Status
Paid
Code
AB-1205
Passenger
Eka Putri
Seats
4
Status
Paid
Code
AB-1201
Passenger
Budi Santoso
Seats
2
Status
Paid
Code
AB-1202
Passenger
Ani Rahmawati
Seats
10
Status
Pending
Code
AB-1203
Passenger
Citra Dewi
Seats
Status
Cancelled
Code
AB-1204
Passenger
dedi Kurniawan
Seats
1
Status
Paid
Code
AB-1205
Passenger
Eka Putri
Seats
4
Status
Paid
Code
AB-1201
Passenger
Budi Santoso
Seats
2
Status
Paid
Code
AB-1202
Passenger
Ani Rahmawati
Seats
10
Status
Pending
Code
AB-1203
Passenger
Citra Dewi
Seats
Status
Cancelled
Code
AB-1204
Passenger
dedi Kurniawan
Seats
1
Status
Paid

14 rows in a box capped at max-h-96 (the maxHeight prop) — scroll it and the header stays put. Without that height limit, stickyHeader cannot do anything: the table's own horizontal-scroll wrapper would become the nearest scroll container instead, and it never scrolls on its own.

<UiTable :columns="columns" :rows="rows" row-key="id" sticky-header />

Empty and loading

<UiTable :rows="[]" empty-title="No bookings yet" />

One deliberate limit:stickyFirstColumn freezes the first column and only the first. Arbitrary sticky columns need a left-offset engine that is fragile and easy to get a pixel wrong, and the real need is almost always a single identity column on the left. On narrow screens the table becomes a card list instead — the same rows, drawn as label and value pairs, chosen by a CSS breakpoint rather than by measuring the window, because measurement has no answer while the page is rendering on the server.

PropTypeDefaultDescription
columnsTableColumn[]Required. { key, label, sortable?, align?, width?, hideOnMobile? }
rowsT[]Required. The data.
rowKeystring | ((row) => string | number)Required. Identity of a row — indexes shift the moment you sort.
sortSortState | nullnullTwo-way with v-model:sort. Click cycles asc → desc → none.
selected(string | number)[][]Two-way with v-model:selected. Holds row keys.
expanded(string | number)[][]Two-way with v-model:expanded.
searchstring''Two-way. Table stores it; DataTable draws the search box.
searchKeysstring[][]Columns the free-text search looks at.
selectable / expandablebooleanfalseAdds the checkbox column / the expand column.
paginate + perPageboolean + numberfalse + 10Slices rows and shows the pager.
stickyHeaderbooleanfalseFreezes the header row while the body scrolls. Needs maxHeight — see below.
maxHeightstring'max-h-96'Tailwind max-h-* class for the scroll wrapper. Only applied when stickyHeader is on — a sticky header needs a bounded height to scroll against.
stickyFirstColumnbooleanfalseFreezes the first column only — see the note below.
dense / loadingbooleanfalseTighter rows / spinner instead of the empty state.
manual + totalboolean + numberfalseServer-side mode: the table stops sorting and slicing, and reports state instead.
mobileCardsbooleantrueRenders a card list below the sm breakpoint.
@row-click(row: T) => voidCheckbox and expand clicks do not trigger it.

Timeline

A vertical history of events — connecting line, tone-colored markers, time, and free-form content per event. The last event deliberately doesn't grow a line downward; a line dangling at the end makes the list look cut off, as if there's still more to load.

Ticket booking history

  1. Order created

    08:10

    By Bandung agent

  2. Paid

    08:12

    QRIS · Rp 185,000

  3. Checked in

    19:40

    Seat 4A, Leuwipanjang Terminal

  4. Departed

    20:00
<UiTimeline :items="pemesanan" />

Dense

  1. Order created

    08:10

    By Bandung agent

  2. Paid

    08:12

    QRIS · Rp 185,000

  3. Checked in

    19:40

    Seat 4A, Leuwipanjang Terminal

  4. Departed

    20:00
<UiTimeline :items="pemesanan" dense />

No history yet

No history yet

<UiTimeline :items="[]" empty-text="No history yet" />
PropTypeDefaultDescription
itemsTimelineItem[]Required. Ordered from oldest to newest event.
densebooleanfalseTightens the spacing between events.
emptyTextstring'Nothing recorded yet'Text shown when items is empty.

Tree

The tree is rendered flat, not nested — each row is a sibling <li role="treeitem"> carrying its own aria-level, the second valid form per the ARIA Authoring Practices. The alternative, recursively nested components, forces focus tracking to jump across component boundaries — two sources of bugs for zero benefit. Focus uses roving tabindex like Tabs: exactly one row has tabindex="0", the rest -1", and the arrow keys actually move it with .focus(), not just a visual highlight.

Province → city → terminal region

  • Jawa Barat
  • Bandung
  • Terminal Leuwipanjang
  • Terminal Cicaheum
<UiTree :nodes="wilayah" v-model:expanded="dibuka" aria-label="Operational region" />

Controlled via v-model:selected

Selected: none yet

"Terminal Tirtonadi" is marked disabled — visible but cannot be selected or receive keyboard focus.

<UiTree :nodes="wilayah" v-model:selected="terpilih" />
KeyAction
Move to the next visible row.
Move to the previous visible row.
Open a closed node; if already open, move to the first child.
Close an open node; if already closed, move to the parent.
HomeJump to the first visible row.
EndJump to the last visible row.
EnterSelect the currently focused node.
PropTypeDefaultDescription
nodesTreeNode[]Required. { id, label, icon?, disabled?, children? }.
ariaLabelstringundefinedAccessible name for the tree — set it when there's more than one tree on the page.
expandedstring[][]Two-way via v-model:expanded. List of currently open node ids.
selectedstring | nullnullTwo-way via v-model:selected. Id of the selected node.
@select(node: TreeNode) => voidEmitted when Enter or a click selects a node that is not disabled.
#item{ node, level, expanded }Replaces the content of a single row; the open/close chevron stays default.