Skip to content
MCG Filament CMS Demo

Skins and the design-token system

This page explains the theme's design-token layer, how to restyle a site by reassigning tokens, and how to write, register and load a skin. It is written for a designer or front-end developer adopting or replacing the default theme.

The token layer

Every color, font and measurement is a custom property declared once in @theme, with a name starting --cms- or --color-cms- so it cannot collide with the host application's tokens. No component rule names a raw color value. Because of this rule, a skin can be a block of token reassignments rather than a second stylesheet. If a component rule needs a value that has no token, add the token first.

@theme             declares the tokens; Tailwind turns them into utilities
                   (bg-cms-surface, font-cms-display, text-cms-hero, …)
dark mode          reassigns them under prefers-color-scheme and [data-cms-theme]
skins              reassign them under [data-cms-skin]
@layer components  consumes them, and only them

The token groups

The tokens fall into twelve groups. A skin usually reassigns the type, surface, ink and accent groups and leaves the other eight unchanged.

Group Tokens
Type --font-cms-display, -body, -prose, -mono
Surface --color-cms-surface, -raised, -sunken
Ink --color-cms-ink, -muted, -faint
Line --color-cms-line, -line-strong
Accent --color-cms-accent, -hover, -ink, -faint, --color-cms-link
Status --color-cms-danger, -success
Code --color-cms-code-keyword, -property, -type, -value, -number, -variable, -comment, -operator
Shape --radius-cms, -sm, -lg
Measure --cms-width-narrow, -normal, -wide, --cms-measure, --cms-measure-tight
Rhythm --cms-gutter, --cms-rhythm
Scale --text-cms-hero, -title, -section, -lead (all clamp())
Motion --ease-cms

Colors are declared in oklch. Lightness in oklch is perceptual, so a skin can change a color's hue without changing how heavy its tints look. Type sizes use clamp() rather than breakpoints, so headings scale continuously instead of changing size in one step at a single viewport width. Keep both conventions when adding tokens.

The theme has four type roles: display for headings, buttons, navigation and labels; body for interface text; prose for long-form reading; and mono for code, meta text and URLs.

--color-cms-link colors links in prose and defaults to var(--color-cms-accent), so a skin that reassigns the accent changes its links too. The token exists separately for brands where the two must differ: a black-and-white identity has a black accent, and links that inherit black are invisible on a dark band. Reassigning --color-cms-link changes the links and leaves the buttons on the accent.

Tokens a skin cannot reassign

Seven tokens style the front-end editor's controls: --color-cms-inline-add and --color-cms-inline-add-ink (the outline, pencil, + and region tag), --color-cms-inline-neutral and --color-cms-inline-neutral-ink (an empty region's box and the drag grip), --color-cms-shared-edit and --color-cms-shared-edit-ink (the same controls on a block in a shared region), and --font-cms-inline. They are marked skinnable: false in DesignTokens and are the only tokens with no dark value, so an editor sees the same controls on every site. The cms_validate_skin MCP tool does not report them as tokens a skin failed to set.

Before v0.54.0 the outline, pencil, grip and empty region used the skin's accent, raised, muted, line-strong and faint colors, and all of the controls inherited --font-cms-body, so the same control looked different from site to site. No skin file needs a change: the tokens a skin sets still style everything else and no longer reach the editing controls.

Retheming without a skin

A site that needs one appearance, rather than a set an editor can switch between, does not need a skin. Reassigning the tokens on :root restyles every block on the site, because no component rule names a raw value:

resources/css/app.css CSS

@import 'tailwindcss';
@import '../../vendor/mcg/filament-cms/resources/css/cms.css';

:root {
    --color-cms-accent: oklch(0.55 0.19 25);
    --font-cms-display: 'Your Face', serif;
    --radius-cms: 0;
    --cms-measure: 64ch;
}

Writing a skin

A skin is a CSS file scoped to [data-cms-skin='name'] and, optionally, a Blade partial with the same name. cms:make-skin generates both. In a terminal at the site's root:

php artisan cms:make-skin Foundry            # resources/css/skins/foundry.css
php artisan cms:make-skin Foundry --partial  # and resources/views/cms/skins/foundry.blade.php

The name is converted to a slug. If a file already exists, the command asks before overwriting it in an interactive terminal, and otherwise stops unless --force is passed.

The generated stylesheet contains the token groups a skin usually reassigns, two dark-mode declarations (a media query for the operating system preference and an attribute rule for a theme the visitor forced), and a .cms-main--home rhythm override rather than a --cms-rhythm on the root.

The generator does not add the @import or register the name. It prints both steps instead, because both edit a file the host application owns and formats to its own conventions, and a pattern-matched edit could corrupt it. Until both steps are done the stylesheet is inert: nothing imports it, and the attribute it is scoped to is never written onto <html>. The command says so in its output.

An example of a finished skin stylesheet:

resources/css/skins/foundry.css CSS

[data-cms-skin='foundry'] {
    --font-cms-display: 'Space Grotesk', ui-sans-serif, sans-serif;
    --font-cms-body: 'Inter', ui-sans-serif, sans-serif;

    --color-cms-surface: oklch(0.97 0.004 250);
    --color-cms-ink: oklch(0.2 0.02 265);
    --color-cms-accent: oklch(0.52 0.17 258);
    --color-cms-accent-hover: oklch(0.45 0.18 258);
    --color-cms-accent-ink: oklch(0.99 0 0);

    --radius-cms: 0;
}

/* Dark must be declared twice: once for the OS preference, once for a forced
   [data-cms-theme='dark'], so the attribute wins in both directions. */
@media (prefers-color-scheme: dark) {
    :root[data-cms-skin='foundry']:not([data-cms-theme='light']) {
        --color-cms-surface: oklch(0.18 0.012 265);
        --color-cms-ink: oklch(0.95 0.006 250);
        --color-cms-accent: oklch(0.76 0.12 258);
    }
}

:root[data-cms-skin='foundry'][data-cms-theme='dark'] {
    --color-cms-surface: oklch(0.18 0.012 265);
    --color-cms-ink: oklch(0.95 0.006 250);
    --color-cms-accent: oklch(0.76 0.12 258);
}

/* Only rules that cannot be expressed as a token. */
@layer components {
    /* Rhythm on the front page only. See below. */
    [data-cms-skin='foundry'] .cms-main--home {
        --cms-rhythm: 5rem;
    }

    [data-cms-skin='foundry'] .cms-block__heading {
        letter-spacing: -0.03em;
        text-transform: uppercase;
    }
}

In resources/css/app.css, import the skin after cms.css:

CSS

@import '../../vendor/mcg/filament-cms/resources/css/cms.css';
@import './skins/foundry.css';

The --cms-rhythm token

--cms-rhythm is the space between one block and the next. It is a reading measurement rather than a stylistic one: a heading is usually its own block, with its copy in the next block, so --cms-rhythm is the space under every h2 on the page. Every skin leaves it at the base 2rem.

The skins previously set it between 4.5rem and 9rem, which put up to 144px between a section title and the sentence that follows it, and made the two read as unrelated.

Each skin's larger value is still set, on .cms-main--home only. A front page is a sequence of full-width bands rather than a column of prose, and at the reading rhythm a hero, a logo wall and a call to action would sit almost against each other.

Skin Reading Home bands
Base 2rem 5rem
Press 2rem 5.5rem
Signal 2rem 4.5rem
Marquee 2rem 9rem
SaaS 2rem 5rem
Arcade 2rem 5.5rem
Market 2rem 5rem

Registering a skin

A skin name takes effect only once it is registered. An unregistered name is never written onto <html>, so a stylesheet scoped to it matches nothing. SkinRegistry holds the registered names. The six skins the package ships are registered into it on boot in the same way as any other skin.

To register a skin in config/cms.php, add a name and label under theme.skins:

config/cms.php PHP

'theme' => [
    'skins' => ['foundry' => 'Foundry'],
],

A skin distributed as its own Composer package registers itself from its own service provider, so the host application does not have to add a config line:

PHP

app(SkinRegistry::class)->register('foundry', 'Foundry');

Either method makes the name valid: it is offered in the Skin field on the Site settings screen, and data-cms-skin="foundry" is written onto <html>. The registry records the name only; the application ships and imports the stylesheet.

The label is optional. Without one, the key is converted with Str::headline(), so foundry is labeled "Foundry".

The package registers its skins in boot() rather than register(), and a later registration of the same key replaces an earlier one. A skin package that boots after the CMS can therefore relabel one of the six, or replace it by shipping its own stylesheet under the same name.

Resolution order:

  1. The skin stored in site settings.
  2. cms.theme.skin.
  3. null, which is the base theme.

The config value is the deployed default and travels with a release. The stored value is an editor's override, so the skin can be changed on a live site without a deploy and cleared ("Use the deployed default") to fall back. A name that is not registered, such as a typo or a skin whose package was removed, resolves to null rather than throwing an exception, and the site renders without the skin's styling.

The six shipped skins are press, signal, marquee, saas, arcade and market. Theme::SKINS holds those six names. It is not the list of valid skins; it records which skins came from the package, and cms_theme_state reports a skin in it as package and any other as host.

Reproducing a design file

When a site is built to match a design file, such as a Figma file, the result is a new skin in the application. A core skin (press, signal, marquee, saas, arcade or market) and the package's base styles are never edited, for two reasons:

  1. Every site using that core skin would change with it.
  2. The files live under vendor/mcg/filament-cms, so the next composer update replaces the edit.

A core skin can be used as the starting point:

  1. In a terminal at the site's root, copy the core skin's stylesheet under the new name:
    cp vendor/mcg/filament-cms/resources/css/skins/press.css resources/css/skins/foundry.css
  2. In resources/css/skins/foundry.css, replace every data-cms-skin='press' with data-cms-skin='foundry'. The core stylesheets scope every rule with that attribute selector, in single quotes, and a rule left on the old name continues to style the core skin.
  3. In resources/css/app.css, import the file after cms.css. In config/cms.php, register foundry, as described under Writing a skin and Registering a skin.
  4. In config/cms.php, list the copy's fonts under theme.skin_fonts.foundry. The core skin's fonts are keyed by the core skin's name and are not applied to the copy.
  5. In a terminal at the site's root, run npm run build.
  6. On the Site settings screen, select the skin in the Skin field.

The MCP prompt cms_design_from_figma gives the full order of work for a Figma file, from reading the design system to a written plan for sign-off.

Skin partials

A skin that needs runtime behavior, such as pointer coordinates, a canvas or a scroll observer, ships a Blade partial with the same name as the skin. <x-cms::head> includes the partial if it exists. This is a naming convention, not a registry.

Theme::skinPartial() checks the application's view cms.skins.{skin} first (resources/views/cms/skins/{skin}.blade.php) and falls back to the package's cms::skins.{skin}, so a skin registered from outside the package can carry a partial. None of the six shipped skins has one; kinetic was the only skin with a partial, and it has been removed. cms:make-skin --partial writes a partial into the application's namespace with a prefers-reduced-motion: reduce check and a requestAnimationFrame batching pattern already in place.

A partial is needed only for values CSS cannot read: the pointer position, the scroll distance, or a size measured at runtime. Publish those values as custom properties and let the stylesheet decide how to use them. Anything that can be written in CSS belongs in the stylesheet, where a designer can change it without editing JavaScript.

Fonts, dark mode and the theme switch

Six keys under cms.theme control the site's appearance, independently of the blocks on any page:

config/cms.php PHP

'theme' => [
    'skin'       => null, // null | press | signal | marquee | saas | arcade | market | one you registered
    'skins'      => [],   // further skins, as key => label
    'skin_fonts' => [],   // Bunny families for a skin you registered, as key => families
    'mode'       => null, // null follows the OS; 'light'/'dark' stamps data-cms-theme
    'toggle'     => true, // a visitor-facing light/dark switch in the header
    'fonts'      => true, // Bunny Fonts, per skin
],

Fonts are loaded from Bunny Fonts rather than Google Fonts: the same API and families, served from the EU without tracking. No fonts are bundled, so no font license is distributed with the package. Set fonts to false to serve your own fonts, and point --font-cms-display and --font-cms-body at them.

mode is the site's default. The theme switch is the visitor's override, stored in their browser under the localStorage key cms-theme. The two are independent: a site can force dark and still let a visitor switch to light, or follow the operating system and offer no switch.

A blocking script in <head> runs before the stylesheet is requested. It applies the visitor's stored choice, so a visitor who chose dark is not shown a white page during the stylesheet fetch, and it adds the cms-js class to <html>. The menu button and the theme switch are hidden until that class is present, so a browser without JavaScript shows the plain wrapping navigation rather than buttons that do nothing.