GUI Styling & UX Guide
The official style guide for SveltyCMS native components, ensuring a consistent, accessible, and high-quality user experience.
On this page
SveltyCMS admin UI is built on native Svelte 5 components, Tailwind CSS v4, and the admin theme token system. Prefer composition of shell + primitives over one-off page chrome.
Mandatory shells
| Component | Owns |
|---|---|
AdminPageShell |
The (app) page root: admin-theme-container, --admin-bg-page, PageTitle, header actions, section spacing |
AdminCard |
Each content block: --admin-bg-card, density radius, and the theme’s border width and shadow |
PageTitle |
The heading. Rendered by AdminPageShell. A page does not add its own <h1> |
AdminPageShell fills the route main. Its actions snippet is the header button row. Its children are the body, spaced with spaceY (6 by default). fullHeight is the editor layout, where the shell itself does not scroll.
AdminCard with no variant and no preset follows the active theme: flat drops the border and the shadow, bordered keeps a hairline and a small shadow, elevated uses the larger shadow. Leave hover:shadow-lg off the card. Set variant or preset only for a status surface; that path renders Card and skips the --admin-* inline styles.
Props, the render order, and a full page example are in Page shells.
<AdminPageShell title="Example" icon="mdi:cog" description="…">
<AdminCard class="p-4">…</AdminCard>
</AdminPageShell>
Native primitives
Compose pages from the native controls in src/components/ui, imported from @components/ui:
<Button>,<Badge>,<Input>,<Select>,<Textarea>,<Toggle>,<Checkbox>,<Modal>
Props, accessible names, and the Live Preview coverage of each one are in Native controls. The rest of the barrel (Drawer, Combobox, Tabs, and the others) is listed on Native UI.
Never mix component CSS classes (class="badge" on a <Button>, class="btn" on a <Badge>).
See them painted in the active theme at /config/design-system?tab=preview (Button, Badge, Input, Select, Textarea, Toggle, Card).
Content insets
Collection content is padded by the component, never by the route page. <EntryList> and <Fields> both apply CONTENT_PADDING (ps-2 pe-2 from src/components/collection-display/content-classes.ts) to their own chrome — toolbar, status facets, table shell, tab strip and form. The [language]/[...collection] page renders the components directly with no padding wrapper. The value matches AdminPageShell’s px-2 gutter, so collection pages sit at the same inset as every other admin route.
This keeps list and edit modes aligned with the page title and avoids the page + component double inset that used to push the editor form inward twice. A new collection surface should consume CONTENT_PADDING rather than define its own ps-*/px-*.
Semantic admin surfaces
Use role tokens from src/utilities.css instead of paired light/dark surface classes for admin chrome:
| Token | Role |
|---|---|
--admin-bg-page |
Page canvas |
--admin-bg-card |
Elevated card (dark mode sits above page) |
--admin-bg-sidebar |
Sidebar |
--admin-border-default / --admin-border-subtle |
Borders |
--admin-text-body / --admin-text-muted |
Text |
Shadows and border width follow card variant (flat | bordered | elevated) via AdminTheme.cardShadow / cardBorder. Do not hardcode hover:shadow-lg on every card — that breaks flat themes.
Reference implementation: the setup wizard (src/routes/setup/) — 13 files, zero raw palette shades in markup. Chrome composes the --admin-* roles above, status states use the shade contract, ink on {hue}-500 fills is text-white, and decorative tokens come from --admin-bg-chip / --admin-border-subtle. Copy its class strings when building new chrome.
Theme/shorthand palette import maps surface into full shade scales and rebinds --admin-bg-* in generated customCss (theme-preset-mapper.ts). The generated CSS is scoped to :root, .admin-theme-container, [data-admin-theme] so a theme change repaints every element — utilities, --admin-* roles, scrollbars, and portalled overlays — with no per-component JS.
Radii
Radii are density-driven, not per-component. The four tokens live in src/utilities.css (:root + [data-density="compact"|"spacious"]) and are mirrored onto <html> by (app)/+layout.svelte, so portalled overlays (modal, drawer, popover, tooltip, command palette) resolve the same values as the page.
| Token | Role | cozy | compact | spacious |
|---|---|---|---|---|
--admin-radius-card |
Surfaces: cards, modals, popovers, menus | 0.75rem |
6px |
16px |
--admin-radius-input |
Inputs, menu rows, small icon buttons | 0.375rem |
4px |
10px |
--admin-radius-button |
Buttons, chips, keycaps | 0.25rem |
0.125rem |
0.625rem |
--admin-radius-base |
Legacy .wrapper chrome |
0.75rem |
0.25rem |
1rem |
src/app.css aliases Tailwind’s radius scale onto those tokens, so every rounded-* utility follows the active density — including portalled overlays:
| Utility | Resolves to |
|---|---|
rounded |
--admin-radius-input |
rounded-sm |
--admin-radius-button |
rounded-md, rounded-lg |
--admin-radius-input |
rounded-xl, rounded-2xl, rounded-3xl |
--admin-radius-card |
Rules:
- Never hardcode a pixel radius. Use a scale utility (
rounded-lg) or, when the semantic role must win over the scale step, an explicit token:rounded-(--admin-radius-card)/rounded-(--admin-radius-input)/rounded-(--admin-radius-button). - Surfaces take card, controls take input/button. Overlay panels (popover, modal, drawer, tooltip, dropdown/toast/command-palette panel) use
--admin-radius-card; rows, fields and small icon buttons inside them use--admin-radius-input; the trigger is aButton— do not override it withrounded-xl. - Bare
roundedis density-aware (it resolves to--admin-radius-input); the unsuffixed and suffixed steps share the same four tokens, so there is no fixed-pixel escape hatch left. rounded-fullremains correct for pills, avatars, badges, spinners and toggle knobs.
Motion & Transitions
All shared motion lives in src/utils/admin-transitions.ts and is reduced-motion safe (drops to 0ms when prefers-reduced-motion: reduce or the theme’s reducedMotion feature is enabled).
| Transition | When to use |
|---|---|
adminPage |
Page entry (240ms fade + subtle rise) — applied once by (app)/+layout.svelte, keyed on page.url.pathname |
adminFade |
Modal / section reveals (200ms fade) |
adminStagger |
Card grids / list rows — pass index for a cascade |
adminSlide |
Drawers / sidebars — signed distance for direction |
View Transitions: on browsers that support document.startViewTransition, (app)/+layout.svelte wraps SPA navigations in a native cross-fade via SvelteKit’s onNavigate. adminPage remains the fallback (and the only path under reduced motion) — the two never double-animate.
Try the Motion & transitions section on Design System → Live Preview (/config/design-system?tab=preview) for interactive demos of every transition and the --admin-motion-* timing tokens.
<AdminCard in:adminStagger={{ index: i }}>…</AdminCard>
Rules:
- Do not add per-page
in:fly/in:scaleone-offs — the layout owns the single page transition so every route (dashboard → user → system settings) moves identically. - Hover / active micro-interactions: 150–200ms
transition-allwithease-out. - Animate only
transform+opacity(neverwidth,height,top,gap) for jank-free motion. - Query-parameter navigation (
?edit,?create, filters) must not replay the page transition — that is why the layout keys onpathnameonly.
Color conventions
- Neutral aliases:
gray,slate,neutral,zinc,stoneare aliased to the surface ramp in@theme(--color-gray-500: var(--color-surface-500)), so every neutral utility follows the active theme. Do not reintroduce independent neutral ramps. - Alpha on tokens: use
color-mix(in srgb, var(--color-primary-500) 30%, transparent). Never writergb(var(--color-primary-500) / 0.3)— tokens resolve tooklch()/hex at runtime, which is invalid insidergb()and the declaration is silently dropped (this left scrollbars and drag outlines unstyled in 8 files before the fix). - Hardcoded
bg-white/text-blackon themeable surfaces breaks dark mode — usesurfacetokens or paireddark:variants. Legitimate exceptions: image overlays, QR cards (scanning needs a white canvas), always-dark error pages.
Status-shade contract
One hue, one job. Every semantic ramp (error, success, warning, tertiary, primary, secondary, surface) uses the same shade step for the same role. Reaching for adjacent shades is what produced “too many different reds” across the admin. The drift guard scripts/lint/consolidate-shades.ts --check runs inside bun run check and exits 1 on any violation; run the same script without --check to auto-repair (idempotent, re-run after any big UI sweep).
| Role | Light | Dark |
|---|---|---|
| Solid fill / icon chip | {hue}-500 |
{hue}-500 |
| Accent text | {hue}-500 |
{hue}-400 |
| Emphasis text (headings, bold counts on washes) | {hue}-600 |
{hue}-400 |
| Subtle wash (panel / chip / alert background) | {hue}-500/10 |
{hue}-900/20 |
| Active / toggle wash | {hue}-500/20 |
{hue}-500/20 |
| Soft border (status alert outline) | {hue}-500/30 |
{hue}-500/40 |
| Accent border | {hue}-500 |
{hue}-400 |
| Focus ring | {hue}-500/20–/40 |
same |
Forbidden drift (the consolidation removes these)
- ❌
bg-{hue}-50/bg-{hue}-100— usebg-{hue}-500/10 - ❌
bg-{hue}-500/5//15— use/10 - ❌
text-{hue}-700/-800in light mode — use-600 - ❌
dark:text-{hue}-200/-300— use-400 - ❌
border-{hue}-200/-300— use-500/30;border-{hue}-700/-800— use-500/40 - ❌ Legacy Tailwind hues in admin chrome:
red→error,rose→error,blue→tertiary,green/emerald→success,amber/orange/yellow→warning
Deliberate exceptions
- Filled
warningsurfaces use dark text ({hue}-900/-950), not white — white-on-amber fails WCAG AA (restart-required banner, highlighted search terms). - Light text on dark status surfaces (
text-{hue}-50/-100onbg-{hue}-900/*) stays paired — error toasts, dark modal tinting. - Decorative brand hues (
indigo,purple,pink,violet,teal,cyan) are allowed for seasonal effects, preset swatches, FloatingNav spokes and non-theme data viz — never for status semantics. {hue}-900/-950solid fills and{hue}-900/10remain valid dark-surface accents;{hue}-400solid is allowed for small status dots on dark surfaces.src/app.csspreset recipes are owner-tuned: thepreset-tonal-*/preset-outlined-*/preset-ghost-*/preset-soft-surfaceutilities intentionally usetext-{hue}-700anddark:text-{hue}-200/-300emphasis steps for contrast on washes.src/app.cssis exempted inconsolidate-shades.ts(EXCLUDED) so the guard never rewrites it — component code must still follow the contract.
Canonical recipes live in src/app.css (preset-tonal-*, preset-outlined-*, preset-ghost-*) and src/components/ui/status-badge.svelte. The --admin-* token layer in src/utilities.css stays the single source for chrome surfaces.
Responsive behavior
Every (app) route must work at mobile (<768px), tablet (768–1023px), and desktop (≥1024px):
- CSS layout: Tailwind
sm:/md:/lg:variants. - JS logic: the
screenstore (screen.isMobile/isTablet/isDesktop). - Wide tables / panels go in an
overflow-x-autowrapper. - Never rely on
hover:alone for primary actions (touch devices), and keep touch targets ≥ 40px.
RTL / LTR
SveltyCMS is fully localized (Paraglide) and must render correctly in both directions. Use logical properties so one class set works for LTR and RTL:
- Margins/padding:
ms-/me-/ps-/pe-(neverml-/mr-/pl-/pr-). - Positioning:
start-/end-/inset-s-/inset-e-(neverleft-/right-). - Alignment:
text-start/text-end(nevertext-left/text-right). - Corners/borders:
rounded-s-/rounded-e-/border-s-/border-e-(neverrounded-l-/rounded-r-/border-l-/border-r-). - Transforms (
-translate-x-*) are physical — for directional icons (chevrons), mirror them withrtl:-scale-x-[-1].
Exception: image editors / canvas overlays use physical coordinates (left-1/2, -translate-x-1/2) because the canvas itself is never mirrored.
Light & dark mode
- Semantic
--admin-*roles flip automatically viahtml.dark— prefer them over paireddark:surface classes on chrome. dark:variants are allowed for one-off overrides (gradients, accent chips), not for page chrome.- Status colors come from theme ramps (
error,warning,success) — do not hardcode Tailwind’sred-*/amber-*/emerald-*for state.
Design System workspace
Canonical route: /config/design-system
| Tab | Purpose |
|---|---|
?tab=overrides |
Per-user density, card style, a11y, layout — linked from /user |
?tab=preview |
Live component catalog + token readout |
?tab=presets |
Palette studio (seed colors → runtime CSS) + Theme JSON import |
?tab=themes … |
Admin workspace themes, layout density, features, advanced |
Palette studio writes a marked customCss block via buildPaletteCssFromSeeds / mergePaletteCssIntoCustomCss — do not invent a second token system.
See Design System & Admin Theme Settings.
Accessibility
- Interactive controls need accessible names
- Deterministic unique IDs for ARIA/controls pairing: use Svelte 5’s
$props.id()rune (const uid = $props.id()), nevercrypto.randomUUID()at render time which creates SSR/hydration ID mismatches - Localized date/time display: use
formatDate(),formatDateTime(),formatTime()from@utils/format-dateor@utils/datepassingsystemLanguageto avoid hydration text flash - Prefer logical Tailwind properties (
ps-,pe-) for RTL - Respect theme Reduced Motion and High Contrast
- Keyboard focus is visible in both modes (
focus-visiblerings use theme tokens, neveroutline: none)
What not to do
- ❌ Parallel global token systems that collide with
surface-*palettes - ❌ Hand-rolled page shells instead of
AdminPageShell - ❌
crypto.randomUUID()inside component scripts or prop defaults for DOM/ARIA IDs — use$props.id()rune - ❌ Unpinned
date.toLocaleDateString()/date.toLocaleString()without explicit locale — use@utils/format-datehelpers - ❌ Soft-skipping E2E because lists are empty — seed fixtures instead
- ❌
rgb(var(--color-*) / a)on palette tokens — silently invalid; usecolor-mix() - ❌ Per-page transition one-offs — use
adminPage/adminStaggerfrom@utils/admin-transitions - ❌ Hardcoded
bg-white/text-black/ Tailwindgray-*-as-semantics on themeable surfaces - ❌ Shade drift (see Status-shade contract):
bg-{hue}-50washes,text-{hue}-700emphasis,border-{hue}-200soft borders, legacyred/blue/greenclasses