npm

Getting started

Configuration

There are two layers. Environment variables cover per-deployment branding, so you can rebrand a clone without editing code. VuiProvider covers how the components behave. Everything is optional: unset means the theme as shipped.

How do I change the way the components behave?

Wrap the app in VuiProvider and set the keys you want. The theme ships finished and nothing in it is locked: vuiPresetis that finished behaviour as a plain value, applied by default and built from the same API you use, so there is no separate "configurable" set of components beside the opinionated ones.

app/(app)/layout.tsx
import { VuiProvider } from "@viliha/vui-ui/config";

<VuiProvider
  config={{ behaviour: { rowClick: "edit", confirmDiscardWhenDirty: true } }}
  userConfigurable={{ behaviour: ["rowClick", "flashMs", "confirmDelete"] }}
>
  {children}
</VuiProvider>

Values resolve per-instance prop → user preference → your config → vuiPreset → package default, and each layer overrides only the keys it names. So changing one thing never means adopting a config file for everything, and a screen that genuinely needs something different says so with a prop and wins.

Behaviour keys

  • rowClick ("view" the default, "edit", "none"): what clicking a record's name in a table does.
  • closeOnSave (default true): close the form after a successful save. false keeps it open for the next record.
  • flashMs (default 1600): how long a saved row stays highlighted. 0 turns the highlight off.
  • confirmDelete (default true): ask before deleting a row.
  • confirmDiscardWhenDirty (default false): ask before throwing away unsaved edits.

Letting the person using the app change some of it

userConfigurable names the keys you are willing to hand over. Those become theirs, saved per browser and merged over your config; anything not listed is ignored on write, so a stale stored value cannot leak back in. Build the settings UI from useVuiPreferences(), which returns preferences, userConfigurable, setPreference and reset. The Settings page's Data tables section is the working example, and the allow-list lives in lib/app-config.ts beside the top-bar chrome flags it mirrors.

What is not config

Colors, radius, spacing and typography stay in theme.css. That is not a limitation, it is the point: tokens are what keep twelve screens looking like one product, so restyling means changing a token rather than passing a prop. Form composition has its own props (formActions, formSlots, fullWidth), covered on the Data table page.

How do I let users change the theme?

Wrap the app in ThemeConfigProvider. The organization sets the brand and each person overrides the parts they care about, which is how five people in one organization can each have their own colour while the company default still applies to everyone who hasn't chosen.

app/(app)/layout.tsx
import { ThemeConfigProvider } from "@viliha/vui-ui/theme-provider";

<ThemeConfigProvider
  orgTheme={org.theme}                                  // the company brand
  source={{                                             // where a personal theme lives
    load: () => api.get(`/users/${me.id}/theme`),
    save: (theme) => api.put(`/users/${me.id}/theme`, theme),
  }}
>
  {children}
</ThemeConfigProvider>

The package never fetches. source is two functions you implement, so the theme is a row in your database like anything else. Omit it and a personal theme stays in that browser, which is what this demo does.

What can change

THEME_FIELDS is the complete list, and each entry names the CSS variable it writes plus the control a settings UI should render, so a settings screen is a .map() over it: primary colour, text on primary, accent, destructive, page background, text, borders, font, text size, corner radius, logo and favicon.

One brand colour, both modes

A theme sets --brand and nothing else for the primary action. The hover state, focus ring, selection colour and button shadow derive from it in theme.css with color-mix, and so does the dark-mode variant, so one saved value covers light and dark and they cannot drift apart. Text on the brand colour is chosen for you by luminance, so a pale brand gets dark text rather than unreadable white. Fonts come from a curated self-hosted set loaded with next/font, so switching one makes no network request and cannot shift the layout. Each entry in FONT_FAMILIES names a CSS variable your root layout has to define; without it the family falls back to a generic stack and the option looks broken rather than missing, so the provider warns about that in development. Run parseTheme() on whatever your API returns: a stored theme is user input that ends up as a CSS variable.

The app shell renders a slim footer, shared by the app and auth layouts, and its copyright line is env-driven. Both layouts resolve it from a single FOOTER_NOTICE in lib/seo.ts, so they never drift out of sync. Set any of these and rebuild:

.env.local
# All optional; unset falls back to the defaults.
NEXT_PUBLIC_COMPANY_NAME="Acme Inc."
NEXT_PUBLIC_COMPANY_URL="https://acme.com"   # links the company name in the footer
NEXT_PUBLIC_LICENSE="All rights reserved"
# The copyright year is automatic (current/build year); no env needed.

NEXT_PUBLIC_LOGO_URL="/logo.svg"             # your logo from /public; else built-in mark

# …or override the whole footer line at once (wins over the vars above):
NEXT_PUBLIC_FOOTER_NOTICE="© 2026 Acme Inc. · All rights reserved"

# Max pages kept open in the tab strip (default 5, min 1):
NEXT_PUBLIC_MAX_TABS="5"

# How a collapsed sidebar rail reveals a group's sub-items: inline,
# flyout-click, or flyout-hover (default flyout-hover):
NEXT_PUBLIC_SIDEBAR_GROUP_MODE="flyout-hover"
  • NEXT_PUBLIC_COMPANY_NAME sets the company shown in the footer.
  • NEXT_PUBLIC_COMPANY_URL is optional and links the company name.
  • NEXT_PUBLIC_LICENSE sets the license/rights text.
  • Copyright year is automatic (always the current / build year); there is no env var for it.
  • NEXT_PUBLIC_LOGO_URL is your logo image from /public (e.g. /logo.svg); falls back to the built-in mark.
  • NEXT_PUBLIC_FOOTER_NOTICE replaces the entire footer line, taking precedence over the vars above.
  • NEXT_PUBLIC_MAX_TABS sets how many pages the tab strip keeps open before evicting the oldest (default 5).
  • NEXT_PUBLIC_SIDEBAR_GROUP_MODEsets how a collapsed sidebar rail reveals a group's sub-items: inline (expands in the rail), flyout-click (click opens a floating panel), or flyout-hover (hover opens it; default).

Leave everything unset and the footer keeps its default: © 2026 VILIHA PTE. LTD. · MIT Licensed.

Logo & branding

Rename the app with a few env vars. They drive the brand name shown in the sidebar, the wordmark, the auth/onboarding screens, and the browser-tab metadata (the tab title is <name> · <tagline>):

.env.local
NEXT_PUBLIC_APP_NAME="Acme Console"
NEXT_PUBLIC_APP_TAGLINE="Operations Platform"
NEXT_PUBLIC_APP_DESCRIPTION="Acme's internal operations console."
NEXT_PUBLIC_APP_URL="https://console.acme.com"

NEXT_PUBLIC_APP_URL is your deploy origin (no trailing slash). It drives metadataBase, so canonical and Open Graph URLs resolve against your domain instead of the default demo host.

Rebuild after changing env

NEXT_PUBLIC_ vars are inlined at build time. Restart dev(or rebuild) after editing them; a running server won't pick up the change.

Runtime branding from an API (white-label / multi-tenant)

When branding has to come from a backend at runtime (a different name, logo, and tagline per tenant), the env vars are only the defaults. BrandProvider (wrapping the app in the root layout) layers a runtime override on top, and everything (headers, wordmark, the browser-tab title, and the favicon) reads from it. Three ways to feed it:

  • Set NEXT_PUBLIC_BRAND_URL to a JSON endpoint returning any of { name, tagline, description, logoUrl, faviconUrl, company, companyUrl }. The provider fetches it on load and applies it.
  • Seed it from a loader/server response: <BrandProvider initial={brandFromApi}>.
  • Push it imperatively after your own API call: useBrand().setBrand({ name, logoUrl }).
apply an API response at runtime
const { setBrand } = useBrand();
useEffect(() => {
  fetch("/api/tenant/branding")
    .then((r) => r.json())
    .then(setBrand); // { name, tagline, logoUrl, … }
}, [setBrand]);

There are two ways to set your logo, and the common case needs no component code at all:

  • Drop an image + one env var. Put your file in public/ (e.g. public/logo.svg) and set NEXT_PUBLIC_LOGO_URL="/logo.svg", then rebuild. It renders in the sidebar header (and anywhere <Logo /> is used).
  • Leave it unset→ the built-in rounded badge with a stylised “V” shows instead. Its color is the --brand-indigo token; change it in theme.css to recolor the fallback.

For more control (a separate wordmark, a dark-mode variant, or custom sizing), edit app/_components/logo.tsx directly (it takes a variant and className).

Favicon (browser-tab icon)

The static icons in app/ (icon.svg, icon.png, apple-icon.png) are the default, replace those files to change the icon at build time. To make it configurable the same way as the rest of the brand, set a favicon URL and BrandProvider swaps the tab icon:

.env.local
NEXT_PUBLIC_FAVICON_URL="/favicon.png"
  • From env: drop the file in public/ and set NEXT_PUBLIC_FAVICON_URL, then rebuild.
  • From an API / database (per tenant): return faviconUrl in your branding JSON (or call useBrand().setBrand({ faviconUrl })). The tab icon updates live, no rebuild.
  • Unset → the static app/icon.* files are used.

Open tabs

NEXT_PUBLIC_MAX_TABS (above) caps how many pages the tab strip keeps open. The strip itself is an app-shell pattern you wire in. See Navigation & tabs for the setup.

Data-table cell truncation

NEXT_PUBLIC_MAX_CELL_CHARS sets how many characters a RecordView cell shows before it truncates to one line with an ellipsis and a hover tooltip; long text never wraps to a second row. Default 25. Override per table with the maxCellChars prop, or per column with a field's maxChars (0 disables truncation for that column).

.env.local
NEXT_PUBLIC_MAX_CELL_CHARS="25"

Data-table column resizing

NEXT_PUBLIC_RESIZABLE_COLUMNS lets users drag a RecordView column's right edge to resize it. It is on by default, so a long value in a narrow column is always reachable; set it to 0 (or false) to turn resizing off, or pass resizableColumns={false} on a single table.

.env.local
NEXT_PUBLIC_RESIZABLE_COLUMNS="1"

Password field masking

NEXT_PUBLIC_PASSWORD_MASK sets how every PasswordInput hides its value. "asterisk" (default) draws * over the field for a consistent look; "native"uses the browser's bullet-dot type="password" so password managers and autofill work normally. The eye toggle reveals the value in both. Override a single field with the mask prop.

.env.local
NEXT_PUBLIC_PASSWORD_MASK="asterisk"   # or "native"

Build-time values

NEXT_PUBLIC_ vars are inlined at build time into the static export, so set them where your deploy runs pnpm build, not just at runtime. Changing one means a rebuild, not a restart.