Theme System — Lvntr Starter Kit
This document covers:
- The
AppShell/AdminLayoutcomposition model and how to build a new layout - The two-layer theme model: runtime kit themes (
main,aura) and build-time custom slot-overrides - The build-time resolver and full-replacement + fallback model for custom themes
VITE_SK_THEMEactivation for custom themes- Step-by-step recipe for adding a custom theme or overriding a single component's styles
Layout architecture
AppShell — structural shell
resources/js/layouts/AppShell.vue is the structural backbone of the admin panel. It owns:
- The
.admin-layoutroot and.admin-main/.admin-contentregions - Sidebar collapse and mobile-open state via
useSidebar(single owner — do not importuseSidebaragain in child components) - Responsive class modifiers (
admin-main--expanded,admin-main--collapsed,admin-main--mobile) on.admin-main
AppShell exposes five named slots:
| Slot | Receives (scoped) | Purpose |
|---|---|---|
#sidebar |
{ collapsed, mobileOpen, isMobile, closeMobile } |
Sidebar region |
#header |
{ collapsed, isMobile, toggle } |
Top header bar |
default |
— | Page content (.admin-content wrapper) |
#footer |
— | Footer strip |
#overlays |
— | Global overlays (dialogs, toasts, confirm) |
AppShell carries no page props, no flash-to-toast bridge, and no <Head>. It is a pure structural wrapper.
AdminLayout — thin composition
resources/js/layouts/AdminLayout.vue wraps AppShell and fills every region with the standard admin components:
#sidebar→AdminSidebar#header→AdminHeader(forwards dark-mode toggle)- default →
AdminPageHeader+<slot />(page content) +<slot name="page-actions" /> #footer→AdminFooter#overlays→ConfirmDialogComponent,ToastComponent,AppDialog,ImageLightbox
It also handles the flash-to-toast bridge (router.on('finish', …)) and <Head :title="title" />.
External contract (unchanged):
<AdminLayout title="Users" subtitle="Manage users" :back-url="route('admin.users.index')">
<template #page-actions>
<Button label="New User" />
</template>
<!-- page content -->
</AdminLayout>
Props: title?: string, subtitle?: string, backUrl?: string | boolean.
Slots: default (page body), page-actions (rendered inside AdminPageHeader's #actions slot).
All existing pages continue to import @/layouts/AdminLayout.vue — the public contract is identical.
Building a new layout
To create a layout that shares the shell structure but arranges regions differently, compose AppShell directly:
<!-- resources/js/layouts/MinimalLayout.vue -->
<script setup lang="ts">
import AppShell from '@/layouts/AppShell.vue';
import MinimalSidebar from '@/layouts/components/MinimalSidebar.vue';
import MinimalHeader from '@/layouts/components/MinimalHeader.vue';
</script>
<template>
<AppShell>
<template #sidebar="{ collapsed, mobileOpen, isMobile, closeMobile }">
<MinimalSidebar
:collapsed="collapsed"
:mobile-open="mobileOpen"
:is-mobile="isMobile"
@close-mobile="closeMobile"
/>
</template>
<template #header="{ collapsed, isMobile, toggle }">
<MinimalHeader :collapsed="collapsed" :is-mobile="isMobile" @toggle-sidebar="toggle" />
</template>
<slot />
</AppShell>
</template>
Pages using the new layout import it by path — AdminLayout pages are unaffected.
Two style layers
The theme system has two independent, complementary layers.
| Layer | Themes | How it activates | Resolver | Artifact |
|---|---|---|---|---|
| Runtime kit themes | main, aura |
appearance.theme DB setting (Settings → Appearance) — takes effect instantly, no rebuild |
useTheme composable sets <html data-sk-theme> at runtime |
None — CSS always bundled |
| Build-time custom themes | Consumer-created theme/<custom>/ directories |
VITE_SK_THEME=custom in .env + npm run build |
sk-theme-build.mjs (vendor-resident) writes _active.css |
Generated file (_active.css), gitignored |
Additionally, the PrimeVue preset layer is keyed off VITE_SK_THEME at build time:
| Layer | What it covers | Resolver | Artifact |
|---|---|---|---|
| PrimeVue preset | Primary palette, surface colours, border radius, component tokens (--p-* variables) |
alias customResolver in vite.config.ts (resolveActivePreset) |
None — pure JS module resolution |
Runtime kit themes — main and aura
Both main and aura are always bundled in every build. Switching between them requires no rebuild — the useTheme composable reads appearance.theme from the shared Inertia props and writes <html data-sk-theme="aura"> (or removes the attribute for main) on mount and on every prop change.
main— the default. Nodata-sk-themeattribute on<html>; all base rules apply.aura— activates whendata-sk-theme="aura"is set on<html>. All aura rules are scoped tohtml[data-sk-theme='aura'], so they are inert until the attribute is present.
Switch from the admin Settings → Appearance panel; the change takes effect immediately and persists admin-wide. There is no per-user override.
Build-time custom themes
Custom themes live in resources/css/theme/<name>/ directories and use the full-replacement + fallback slot model — see CSS theme system below. They require VITE_SK_THEME=<name> in .env and a npm run build / npm run dev to become active. When a custom theme is selected in Settings → Appearance, a "rebuild required" hint is shown in the UI.
The two layers are independent: you can have VITE_SK_THEME=custom (build-time custom base) while the runtime attribute switches between main and aura visual styles. A tokens.css override typically reads the --p-* tokens the preset emits — see Dependency chain below.
CSS theme system
Directory structure
resources/css/
├── theme/
│ ├── theme.css # Entry: imports _active.css then theme-runtime/aura/aura.css
│ ├── _active.css # GENERATED — do not edit; gitignored
│ ├── main/ # Built-in base theme (source of truth for all slots)
│ │ ├── tokens.css # CSS custom properties: layout dims, colours, shadows
│ │ ├── fonts.css # Web font declarations
│ │ ├── _base.scss # Base reset / typography
│ │ ├── layout/
│ │ │ ├── shell.css # .admin-layout, .admin-main, Vue transitions
│ │ │ ├── sidebar.css # .admin-sidebar*, .admin-overlay
│ │ │ ├── header.css # .admin-header*
│ │ │ ├── page-header.css # .admin-page-header*
│ │ │ └── footer.css # .admin-footer*
│ │ ├── components/
│ │ │ ├── card.css
│ │ │ ├── button.css
│ │ │ ├── confirm.css
│ │ │ ├── datatable.css
│ │ │ ├── dialog.css
│ │ │ ├── editor.css
│ │ │ ├── formbuilder.css
│ │ │ ├── menus.css
│ │ │ ├── message.css
│ │ │ ├── navigation.css
│ │ │ ├── primevue.css
│ │ │ ├── tabs.css
│ │ │ ├── tag.css
│ │ │ └── toast.css
│ │ ├── _auth.scss # Auth layout styles
│ │ └── utilities.css # Tailwind utility overrides
│ └── custom/ # Your override theme — not shipped; you create it
│ └── (create as needed — see recipe below)
└── theme-runtime/
└── aura/ # Runtime kit theme — always bundled, scoped to html[data-sk-theme='aura']
├── aura.css # Index: imports the four files below in order
├── tokens.css # Frame/panel/sidebar tokens (scoped + dark + sidebar variants)
└── layout/
├── shell.css # Brand-colored frame + inset rounded panel
├── sidebar.css # Static sidebar on the frame, version chip, mobile drawer
└── header.css # 70px panel header, solid dev/debug tags
The kit no longer ships an empty
custom/directory. If you want to override any slot, create the directory yourself first (see recipes below).
theme-runtime/aura/is not a build-time slot directory. It is not read by the resolver (sk-theme-build.mjs) and settingVITE_SK_THEME=aurahas no effect —auraactivates at runtime via thedata-sk-themeattribute only.
Built-in aura theme
aura is the kit's second built-in theme. It restyles the admin shell as an inset, rounded panel inside a brand-colored frame: the sidebar sits directly on the frame (white active pill in the colored style), and the header/content/footer share the panel surface. It overrides only four visual areas — tokens.css, layout/shell.css, layout/sidebar.css, layout/header.css — everything else (navigation, footer, page-header, all components) falls back to main and is repainted purely by the tokens.
The frame color follows the active PrimeVue primary (--p-primary-800), so the existing accent picker recolors the whole frame; the sidebar style switch (colored / light) and dark mode are fully supported.
Activating aura: open admin Settings → Appearance and select the Aura theme card. The change applies instantly — no build step required. The setting is admin-wide (stored in DB as appearance.theme).
aura's CSS lives in resources/css/theme-runtime/aura/ and is always bundled alongside main. All its rules are scoped to html[data-sk-theme='aura'] so they are completely inert until the runtime attribute is present. The useTheme composable (called from AdminLayout) writes and removes this attribute in response to the Inertia shared appearance.theme prop.
Do not set
VITE_SK_THEME=aura— that variable controls the build-time custom-theme resolver only, andaurais not a slot-based theme. Setting it has no effect onaura's runtime behavior and may produce unexpected results from the resolver.
How the resolver works
sk-theme-build.mjs is a kit-managed build script shipped inside the package (vendor-resident at vendor/lvntr/laravel-starter-kit/resources/js/theme/sk-theme-build.mjs). You do not own or edit this file — only theme/slot/preset files in your resources/ directory are yours to customise. The script is invoked by the skTheme() Vite plugin automatically during dev and build, and it writes theme/_active.css. The model is full-replacement + fallback:
main/is the canonical slot list and import order.- For each slot, the resolver loads
<active>/<slot>if the file exists; otherwise it loadsmain/<slot>. - The result is a single
_active.csswith one@importper slot in the correct cascade order.
If custom/ does not exist at all, the build still completes — every slot falls through to main.
Import order (preserved from the original cascade):
tokens → fonts → _base → layout/* → components/* → _auth → utilities
Every layer is a slot. A custom theme that ships only components/datatable.css overrides just the datatable. Everything else falls through to main. A file in custom/ whose path does not match any main/ slot is ignored — the resolver iterates main's slot list, not the custom directory.
Note: _base.scss and _auth.scss are .scss files — their extension is preserved. The resolver is extension-aware; Sass compilation is handled by the same pipeline as before.
VITE_SK_THEME activation
VITE_SK_THEME controls the build-time custom theme resolver only. It has no effect on the runtime kit themes (main, aura) — those are always bundled and activated via the data-sk-theme attribute at runtime.
Set the active custom theme in .env:
VITE_SK_THEME=custom
The default is main. If the variable is absent or blank, main is used — i.e. every slot resolves to main/ and no custom files are loaded. Run npm run dev or npm run build — the skTheme() Vite plugin guarantees the resolver runs and regenerates _active.css before Vite processes any assets. This approach is safe under ignore-scripts=true because it does not rely on npm lifecycle hooks.
To preview the resolved manifest without a full build, use the theme:build npm script (which runs the vendor script directly):
npm run theme:build
# [sk-theme-build] → resources/css/theme/_active.css (25 slots, 1 override)
Or invoke the vendor script directly:
node vendor/lvntr/laravel-starter-kit/resources/js/theme/sk-theme-build.mjs
Open resources/css/theme/_active.css to see exactly which file each slot resolved to. Overridden slots are annotated with /* override */.
_active.css — generated artifact
_active.css is a build artifact:
- Listed in
.gitignore— never commit it. - Not hash-tracked by
sk:update— it is regenerated on everynpm run dev/npm run build. - Always present before Vite starts because the
skTheme()plugin invokes the resolver before Vite processes assets.
Custom theme recipe
Override a single component
To restyle the datatable without touching anything else:
-
Create the custom directory and copy the slot you want to override:
mkdir -p resources/css/theme/custom/components cp resources/css/theme/main/components/datatable.css \ resources/css/theme/custom/components/datatable.css -
Edit
custom/components/datatable.css. Keep the same class names — the Vue components target them. -
Set
VITE_SK_THEME=customin.env. -
Run
npm run dev. TheskTheme()plugin regenerates_active.cssbefore Vite starts;custom/components/datatable.cssreplacesmain/components/datatable.cssin the import list. All other slots remain frommain.
Verify the resolved manifest:
@import './main/tokens.css';
@import './main/fonts.css';
@import './main/_base.scss';
@import './main/layout/footer.css';
…
@import './custom/components/datatable.css'; /* override */
@import './main/components/dialog.css';
…
@import './main/_auth.scss';
@import './main/utilities.css';
Override the token set
To change layout dimensions, colours, or shadows globally, override tokens.css:
mkdir -p resources/css/theme/custom
cp resources/css/theme/main/tokens.css \
resources/css/theme/custom/tokens.css
Edit the custom properties. All layout regions and components read from these tokens, so a token change propagates everywhere without touching individual component files.
Override a layout region
mkdir -p resources/css/theme/custom/layout
cp resources/css/theme/main/layout/sidebar.css \
resources/css/theme/custom/layout/sidebar.css
Edit as needed. Only the sidebar slot is replaced; the rest of the layout comes from main.
Override fonts or utilities
The font declarations and Tailwind utility overrides are now slots like any other. To swap in your own fonts without touching layout or components:
mkdir -p resources/css/theme/custom
cp resources/css/theme/main/fonts.css \
resources/css/theme/custom/fonts.css
Edit the @font-face declarations in custom/fonts.css. On the next build, _active.css will use your font file instead of the main version; all other slots remain from main.
Similarly, to override Tailwind utility overrides:
mkdir -p resources/css/theme/custom
cp resources/css/theme/main/utilities.css \
resources/css/theme/custom/utilities.css
Because utilities.css is unlayered and emitted last in the cascade, it wins over every layered rule — the same precedence behaviour as before. Edit freely.
Override auth styles
mkdir -p resources/css/theme/custom
cp resources/css/theme/main/_auth.scss \
resources/css/theme/custom/_auth.scss
Edit custom/_auth.scss. The .scss extension is required — the resolver and Sass compiler are extension-aware.
Complete slot reference
| Slot | File | Notes |
|---|---|---|
| tokens | tokens.css |
CSS custom properties (light + dark) |
| fonts | fonts.css |
@font-face declarations |
| base | _base.scss |
Reset / typography; .scss extension required |
| layout/footer | layout/footer.css |
.admin-footer* |
| layout/header | layout/header.css |
.admin-header* |
| layout/page-header | layout/page-header.css |
.admin-page-header* |
| layout/shell | layout/shell.css |
.admin-layout, .admin-main, Vue transitions |
| layout/sidebar | layout/sidebar.css |
.admin-sidebar*, .admin-overlay |
| components/button | components/button.css |
Button severity palette (Tailwind colors) |
| components/card | components/card.css |
|
| components/confirm | components/confirm.css |
|
| components/datatable | components/datatable.css |
|
| components/dialog | components/dialog.css |
|
| components/editor | components/editor.css |
|
| components/formbuilder | components/formbuilder.css |
|
| components/menus | components/menus.css |
|
| components/navigation | components/navigation.css |
|
| components/page-loader | components/page-loader.css |
Full-screen page-switch loading overlay |
| components/primevue | components/primevue.css |
|
| components/tabs | components/tabs.css |
|
| components/tag | components/tag.css |
|
| components/toast | components/toast.css |
|
| auth | _auth.scss |
Auth layout styles; .scss extension required |
| utilities | utilities.css |
Tailwind utility overrides; unlayered, emitted last |
Notes
- A custom theme replaces slots whole-file — there is no cascading diff. Copy the
mainfile as a starting point. - Custom themes cannot add new slots. A file in
custom/that has no matching path inmain/is never imported. VITE_SK_THEME=main(the default) produces a build byte-identical to the stock panel — no custom files are loaded.- The kit does not ship a
custom/directory. Create it yourself before adding overrides; if it does not exist, every slot resolves tomain. - Remove
custom/files you no longer need; the resolver falls back tomainautomatically. - Slots with a
.scssextension (_base.scss,_auth.scss) must keep that extension incustom/too.
PrimeVue preset layer
How it works
resources/js/theme/preset.ts is the base PrimeVue styled-mode preset — it defines the primary palette, surface colours, border radius, and per-component tokens. app.ts imports it as @/theme/preset. The import path never changes.
The alias customResolver in vite.config.ts — the resolveActivePreset helper exported from the kit's vendor-resident vite-plugin-sk-theme.mjs — intercepts the @/theme/preset specifier at build time:
- If
VITE_SK_THEMEis notmainandresources/js/theme/<active>/preset.tsexists → resolves to that override file. - Otherwise → resolves to the base
resources/js/theme/preset.ts.
The base file stays where it has always been — the kit never moves it because it is the most commonly customised file in a consumer project.
Directory layout
resources/js/theme/
├── preset.ts # Base preset (consumer-customizable; stays here)
└── custom/ # Not shipped — you create this when needed
└── preset.ts # (you create this — see recipe below)
The custom/ directory is not shipped by the kit. The custom theme's preset is absent by default, so the base preset is used and the PrimeVue look is byte-identical to the stock panel. Create the directory only when you need a theme-specific preset override.
Give a custom theme its own PrimeVue palette
-
Create
resources/js/theme/custom/preset.ts(create thecustom/directory first if it does not exist). The simplest approach is to import the base preset and override only the palette:mkdir -p resources/js/theme/customimport { definePreset } from '@primevue/themes'; import Material from '@primevue/themes/material'; import AppPreset from '../preset'; export default definePreset(Material, { ...AppPreset, semantic: { ...AppPreset.semantic, primary: { 50: '{emerald.50}', 100: '{emerald.100}', 200: '{emerald.200}', 300: '{emerald.300}', 400: '{emerald.400}', 500: '{emerald.500}', 600: '{emerald.600}', 700: '{emerald.700}', 800: '{emerald.800}', 900: '{emerald.900}', 950: '{emerald.950}', }, }, }); -
Set
VITE_SK_THEME=customin.env. -
Run
npm run devornpm run build. The plugin resolves@/theme/presetto your override; with no override file present it falls back to the base.
Notes
- Export the preset object as the default export — this is what
app.tspasses to PrimeVue'spresetoption. - The override applies only when
VITE_SK_THEME=customandresources/js/theme/custom/preset.tsexists. Any other value falls back to the base. - The kit does not ship a
custom/directory underresources/js/theme/. Create it yourself when you need a theme-specific preset. - There is no generated artifact. This is pure module resolution — no
_activefile to gitignore, no npm chain to maintain.
Dependency chain — tokens and preset
resources/css/theme/main/tokens.css (and any custom/tokens.css override) defines the --admin-* custom properties the layout and components use. These are live references to the --p-* variables the PrimeVue preset emits at runtime:
/* tokens.css — admin roles point at PrimeVue's preset output */
--admin-sidebar-bg: var(--p-surface-900);
--admin-sidebar-item-active-bg: var(--p-primary-color);
Because they are live var() references, changing the primary/surface palette in your preset override flows through automatically — the --admin-* roles re-resolve to the new --p-* values; there is nothing to keep "in sync".
Override tokens.css only if you want to re-map which PrimeVue token an admin role points at (e.g. make the sidebar read a different surface step):
mkdir -p resources/css/theme/custom
cp resources/css/theme/main/tokens.css \
resources/css/theme/custom/tokens.css
Then edit the --admin-* properties to reference your chosen --p-* tokens.
Accent color system
The admin panel supports a runtime accent color that repaints the PrimeVue primary palette and the sidebar surface without any rebuild. The system has two layers: an admin global default (set in Settings → Appearance) and an optional per-user override (set from the header popover). When a user has no personal override, the admin global default applies; when the admin global default is also 'default', the kit primary color is used (blue under main, indigo under aura).
How it works
useAccentColor (vendor-resident composable, resources/js/composables/useAccentColor.ts) drives the accent. On onMounted and on watch it calls:
updatePrimaryPalette(palette)— PrimeVue's runtime palette swap. Replaces the active--p-primary-*CSS custom properties with the chosen Tailwind v4 oklch scale; buttons, links, focus rings, active states, and every--p-primary-colorreference update instantly with no rebuild.data-sk-accenton<html>— a marker used bytokens.cssto apply the deep accent tint to the sidebar surface in light mode. In dark mode, the sidebar always stays the neutral dark surface; only active items and buttons carry the accent color. The marker is absent when the accent is'default'.
The sidebar surface treatment is independent of the accent: the sidebarStyle value ('colored' | 'light') controls a data-sk-sidebar marker on <html>. When data-sk-sidebar="light" is present, the sidebar shows a white/light surface with dark text; when absent (the 'colored' default), the deep accent tint (or neutral dark in dark mode) applies.
Available colors
ACCENT_COLORS lists all 26 selectable names: the 22 standard Tailwind v4 palettes (slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose) plus four custom muted tones (taupe, mauve, mist, olive). The special value 'default' means "use the admin global default" (or fall back to the kit primary if the global is also 'default').
All palette values are Tailwind v4 oklch scales inlined in the composable — they are not read from Tailwind's CSS variables at runtime, because Tailwind tree-shakes unused --color-* variables.
Persistence
Both accent choice and sidebar style are persisted in localStorage:
| Key | Default | Meaning |
|---|---|---|
admin-accent-color |
'default' |
Per-user accent choice; writeDefaults: false (seed value is not written until the user makes an explicit pick) |
admin-sidebar-style |
'colored' |
Per-user sidebar surface treatment |
The writeDefaults: false option means localStorage is empty for a first-time user, so the admin global default is always live until the user explicitly picks a color. One-time legacy storage cleanup (migrateLegacyAppearanceStorage) removes any stale 'default' / false / 'colored' seeds written by older builds that used eager writeDefaults: true.
Admin global default vs per-user override
The appearance shared prop (provided by HandleInertiaRequests on every Inertia response) carries:
| Field | Type | Description |
|---|---|---|
accent_color |
string |
Admin global default accent name, or 'default' |
sidebar_style |
'colored' | 'light' |
Admin global default sidebar style |
dark_mode_default |
boolean |
Admin global dark mode default |
theme |
string |
Active runtime theme ('main' or 'aura') |
useAppearanceDefaults reads this prop. useAccentColor consumes defaultAccent and defaultSidebarStyle from it to seed initial values when the user has made no personal choice.
Configure the global default from Settings → Appearance → Default Color. The admin-side picker shows a live preview: selecting a swatch calls applyAccent(color, { followGlobal: false }), which treats 'default' as the kit primary (because the admin is defining the default and must see what kit-blue looks like). On leaving the tab without saving, the user's own session accent is reinstated.
The header popover available to every admin user shows the same color grid plus a "Default" swatch. Picking a color calls setAccent(color) which persists to localStorage; the watch on accent then calls applyAccent automatically. Choosing "Default" clears the personal override so the admin global default applies again.
Theme interaction
maindefault accent: blue ({blue.x}token references, resolved against the Material preset).auradefault accent: indigo (Tailwind v4 oklch scale). When the runtime theme switches betweenmainandaura,useAccentColorre-applies the palette automatically (awatchonappearance.themetriggersapplyAccent) so a'default'accent picks the correct signature color for the active theme.- The aura frame color (
--p-primary-800) follows the active primary, so the accent picker recolors the entire frame underaura.
Page-loader accent
SkPageLoader — the full-screen animated overlay shown during Inertia page switches — uses --p-primary-color for brand elements (rays, blobs, bouncing dots, wave-peak letter color). Because updatePrimaryPalette updates --p-primary-color at runtime, the page-loader automatically uses whatever accent is active. The overlay background is theme-driven:
main:--admin-sidebar-bg(the dark sidebar surface).aura:--p-surface-900(light) /--p-surface-950(dark) — a neutral dark surface, becauseaura's--admin-sidebar-bgis transparent.
Styles live in the components/page-loader.css theme slot (overrideable via a custom theme). The overlay is SSR-safe and honors prefers-reduced-motion. It is opt-in — mount <SkPageLoader/> in your AdminLayout overlays slot to enable it (see ui-components.md — SkPageLoader).
Dark mode
CSS custom properties for dark mode are declared inside .dark { … } blocks in main/tokens.css (and in the per-region layout files where layout-specific dark overrides exist). If you override tokens.css, copy those .dark blocks too.
Dark mode is toggled by useDarkMode — it adds / removes the dark class on <html>. No build step or separate CSS file is needed.
The aura theme's dark-mode rules are declared as html.dark[data-sk-theme='aura'] { … } blocks inside theme-runtime/aura/tokens.css. Both attributes are independent — dark mode and the theme switch compose correctly in all four combinations (light/dark × main/aura).
Related docs
docs/install.md— first-time setupdocs/update.md—sk:updateand hash-tracked stubsdocs/UPGRADE.md— migration guide for existing projects