@columenlabs/lumen-ui
Lumen's React components for data apps: the app frame, pages and views over your rows, widgets, charts, drawers and pickers, all on one theme. You give it data and a view config; it draws the screen. Built on shadcn/ui (Radix UI, Base UI, Tailwind CSS 4). Its view model is @columenlabs/lumen-ui-types.
- Docs and live examples: https://lumen.columenlabs.com/docs/
- For agents: https://lumen.columenlabs.com/llms.txt
- Source: https://github.com/columenlabs/lumen (
packages/ui). MIT.
Install
Pin both packages to the same exact version.
bun add @columenlabs/lumen-ui @columenlabs/lumen-ui-types
bun add react react-dom @tanstack/react-query @tanstack/react-router # peers
bun add -d tailwindcss @tailwindcss/vite tw-animate-css shadcn
Set up
src/index.css:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@columenlabs/lumen-ui/theme.css";
@source "../node_modules/@columenlabs/lumen-ui/dist";
/* your accent, per app (LumenShell sets data-app on <html>) */
:root[data-app="myapp"] { --primary: oklch(0.55 0.15 160); }
/* optional: numbers in the text font instead of mono (code stays mono via `font-code`) */
:root[data-app="myapp"] { --num-font: var(--app-font); }
At start, before anything renders, tell the kit about your app. The kit names no product: your names and data come in here.
import { configureLumen } from '@columenlabs/lumen-ui';
import { registerDomains } from '@columenlabs/lumen-ui-types/registry';
configureLumen({ prefsCookie: 'myapp_prefs' }); // where display preferences live
registerDomains({ /* your joins, sources and domain keys */ });
Wrap your routes in the shell:
import { LumenShell } from '@columenlabs/lumen-ui';
<LumenShell app="myapp" appLabel="My app" brand={<Logo />} nav={nav} user={{ email, name }}>
<Outlet />
</LumenShell>
Your own parts go in LumenShell's slots: brand, headerActions, panel (a side panel such as an assistant), wrap (providers around the page), shared, profile (the profile menu) and statusEnd (the right end of the status bar).
Use createDeskQueryClient() for the app's TanStack Query client: data stays fresh 5 minutes and cached 30.
What's inside
Frame. LumenShell (sidebar with icons-only collapse and a drawer on phone, header, status bar, Settings, profile menu, ⌘K Find), LumenAppSwitcher and LumenSuiteSwitcher (groups, apps on other sites, a list read from an address), WhatsNew (release notes in a drawer), TasksButton and TasksProvider (background work in the header), NotificationsSettings, McpSettings, sign-in screens (LumenAuthStage, LumenSecondStep, LumenSignInMethods, LumenBackupCodes, LumenBotCheck for Cloudflare Turnstile, authErrorText for errors in plain words), StatusHealth, LumenBoundary (keeps a crash inside its card), LumenPageState (loading, empty and error), lumenToast.
Pages and views. LumenLayout (a page of cards from one view config: filter, tiles, widgets; Edit layout and Edit widget when you pass onConfigChange; a card with header: false is its body alone), LumenGrid (the 6-column grid under it), LumenView and LumenViewCard (a table that filters, groups, pivots and totals the rows you load), LumenTable, LumenTokenizedFilter, LumenWidget and LumenSavedWidget (the frame every card sits in, with its link square and controls), LumenCard (one number and its lines), LumenPager, DetailsTable, LumenTree, LumenCalendar (events by month, week or agenda, in a time zone), LumenBoard (items in columns by stage), useAppView (a page whose layout each person can change and reset).
Charts. LumenChart (one or more symbols' prices, with indicators), LumenEChart, LumenGeoMap, growth and price charts, StackedBars, StackedColumns, TimeLines (lines over time, with an optional right axis), RankedBars (with each bar's share of the total or of the first bar, and an empty state).
Pickers and panels. LumenSelector (every picker), LumenDrawer with DrawerSection (a side panel on desktop, a sheet on phone), LumenTabs, LumenConfirm (destructive confirms), LumenForm, LumenDateInput, LumenChoice, LumenSecret, LumenFileField (drop or pick a file), RenameField, ResetButton, SaveButton with FormIssue (a Save that says why it can't), IconPicker, LumenPlayer (video, audio, or slides over audio, with chapters, a following transcript and resume).
Primitives. The shadcn parts under @columenlabs/lumen-ui/ui/* (ui/button, ui/badge, ui/input, ...) and theme.css (light and dark tokens).
Every export, its props and an example are on the docs and in llms-full.txt.
Rules the kit follows, and apps should too
- Pickers are
LumenSelector, side panelsLumenDrawer, tabsLumenTabs, destructive confirmsLumenConfirm; reach for the rawui/*part only when no Lumen part fits. - Every card body sits in
LumenBoundary, so one crash never blanks a page. - Numbers use tabular figures and a true minus
−; a gain or loss always shows a sign or arrow. - A page's header numbers are metric cards:
TileRowofTile(orTileToggleGroupwhen they pick what the page shows), withfillso they share the row, each withsparkset to its daily history where it has one. Don't build a card of your own for this. - Column widths: the first column (a row's name) grows only to
--lead-col-max(18rem, theme.css); past it the cell truncates on one line and a plain text value shows in full on hover (a rendered cell brings its own hover card). Cap any other column with the field'smaxWidth('12rem'), or lift the cap withmaxWidth: false. In a rendered name cell, put the name in atruncatespan so the ellipsis lands on it, not on a chip after it. - Rows never wrap. Every row (data, group, sub-group, Total) reads on one line at every width. On a phone each row is a one-line card: its name (ellipsis, full name on hover) and one value on the right, the sorted column, else the first number or date. The other values are in the row's drawer or panel. Pick which value a phone shows by sorting (Sort by) or by marking the others
hideOnPhone. - Column alignment is the table's, not the cell's. Text left, numbers right, and a number column has one right edge for its header, values, dashes and Total.
UnitValueputs the unit in a fixed slot after the number (--unit-slot, 2rem) and its status dot just left of the number. In a LumenView table the firstUnitValuedrawn in a column marks it (data-unitson every cell of that column, through theUnitColumnscontext and the cell'sdata-col); theme.css then widens that column's right gutter by the slot and hangs each unit in it. So a plain—,Freeor$0.21an app draws in the same column sits on the digits' edge, and the header sits over the digits. Don't hand-place units, dots or padding to line a column up; if a column is off, draw its numbers throughUnitValue. Outside a LumenView table (a hover card, a tile)UnitValuekeeps its slot inline. - A metered column (use against an allowance) shows one number per cell with
UnitValue(right-aligned number, fixed muted unit slot, status dot), nevervalue [pct]. Offer both readings through the field'sshowAs(Amount / % of allowance, picked per column in Edit view, saved asshows); default amounts for sizes (storage) and shares for capped counts. Put the other reading, the allowance and the cost formula in a hover card. Totals userollRender: sum amounts with their unit; a share is the rows' total over their allowance, never a sum of percents. - A floating bulk-action bar over a table's selection: Apply clears the selection and the bar goes away; the toast's Undo restores the change, not the selection. Cancel keeps the selection.
- Bars in a column line up: use
ShareMeter(fixed track, fixed right-aligned number), never a bar sized by its label. - A shared cap split by who used it (an account's allowance by label):
ShareMeter segments={[{ value, label }]}names each part's share in the bar's hover; the bar itself keeps one colour, its level's, so colour always says how full it is. Roll such rows up withpoolShare(rows, { pool, used, cap })in a field'srollRender: a pool's share is its rows' use over the cap (never a sum or max of their own %), and a group across several pools reads the fullest one (top). - Meter levels are the kit's, not the app's. A usage share reads
ok(green) under 75%,watch(amber) from 75%,alert(orange) from 90%,over(red) from 100%; past 100% theShareMeterstays full and a hatched stub spills past a tick at the cap, up to +50%. A health share (kind="health": uptime, success rate) reads green at 99% and over, amber from 95%, red under 95%.ShareMetertakes its colour from the share unless you passcolor;UnitValue share={0.8}tints its number and dot from amber up (green numbers stay plain); progress cards follow the same levels. For any other mark (a dot, a chip, a "nearest cap" cell) callmeterLevel(share, levels, kind)and colour it withMETER_LEVEL_COLORormeterValueTone(share). Override per app withconfigureLumen({ meterLevels: { watch: 0.8 }, healthLevels: { watch: 0.995 } })(each level can move alone) or per meter withlevels. Don't hand-rollshare >= 0.75checks. - Pace (time to a cap at the recent rate) is the kit's:
burnRate(samples, { per, gauge })over the window's complete samples (hourly, or daily for7d), thentimeToCap(rate, used, cap, resetInHours). A lone sample above 3× the window's median counts as 3× the median (spikessays how many); a run of hot samples counts in full. Show it withTimeToCap(in 2h 05m,At cap,Resets first;shorton a phone), coloured by the meter level of where the meter lands at reset, and pick the window withPaceWindowPicker(6h, 12h, 24h, 7d) in the page header;usePaceWindow()reads the same choice everywhere, kept in the viewer's browser (setDefaultPaceWindowsets the app's default). Where most hours are empty (median 0) a lone nonzero hour counts as 0, and a window with too little use (timeToCap(..., { minTotal }), default 0.1% of the cap) isthin: no time to the cap. Don't hand-roll ETAs or a window toggle. - A change between periods (this week against last) is
trendChange(now, prev, { minBase })drawn byTrendValue:▲ 12%over a big enough earlier period, the plain difference▲ +3when it is at or underminBase,▲ >10×past ten times. A % over a near-empty baseline misleads; never show one. - Table rows are one line. A cell shows one value (with a small bar or tag at most); anything more (other metrics, the allowance, a breakdown) goes in a hover tooltip, never in stacked text or sub-rows.
- Chart colours are theme tokens (
var(--chart-1),useChartTokens()), never hex picked for one theme;LumenEChartresolvesvar(--…)in its option so sparklines read in light and dark. - "No label" filters come built in:
LumenTokenizedFilterpins a "No " row on any text field where some rows have no value (anemptytoken); don't add your own toggle for it. - Fewer tab rows: one row of tabs at most per card; narrower cuts (a product, a type) are fields in
LumenTokenizedFilter, and a link to one opens the tab with that filter chip set. - One selected sidebar row: give each nav item its own route, and let an open parent look neutral.
- What's new is on in every app: pass
releases="/api/releases"toLumenShell, served by lumen-ship'slistReleases. - A Save blocked by its form's checks is a
SaveButtonthat lists each issue, never a baredisabled.
Notices
Charts use TradingView Lightweight Charts™ with its logo off. Please link to https://www.tradingview.com/ somewhere in your app (see NOTICE).
Versions
0.x: any minor may break things until 1.0. Every merge to Lumen's main publishes the next patch of every Lumen package; see the releases.