Appearance
Example: host-authored theme
The demo app ships CascadeTheme — a warm, serif-led theme for a hospitality brand — authored entirely inside the host application. It proves the theme contract needs nothing from the package beyond extending AbstractTheme: one class, one config line, one artisan command.
app/Theming/CascadeTheme.php— the theme classconfig/filamentcraft.php→themes.register— registration
The theme class
A theme is a settings schema plus identity metadata. settingsSchema() returns Category groups; each category holds setting types that map straight onto sidebar controls in the editor's theme panel. Trimmed only where entries repeat — every line shown is verbatim:
php
<?php
declare(strict_types=1);
namespace App\Theming;
use FilamentCraft\Settings\Types\Category;
use FilamentCraft\Settings\Types\Checkbox;
use FilamentCraft\Settings\Types\ColorSchemeGroup;
use FilamentCraft\Settings\Types\Font;
use FilamentCraft\Settings\Types\Range;
use FilamentCraft\Settings\Types\Select;
use FilamentCraft\Theming\AbstractTheme;
final class CascadeTheme extends AbstractTheme
{
public function slug(): string
{
return 'cascade';
}
public function name(): string
{
return 'Cascade';
}
public function description(): ?string
{
return 'Warm, serif-led theme with soft surfaces — built for hospitality and lifestyle brands.';
}
public function settingsSchema(): array
{
return [
$this->colorsCategory(),
$this->typographyCategory(),
$this->buttonsCategory(),
$this->layoutCategory(),
];
}
private function colorsCategory(): Category
{
return Category::make('colors')
->label(__('filamentcraft::filamentcraft.editor.settings.colors'))
->icon('heroicon-o-swatch')
->settings([
ColorSchemeGroup::make('color_scheme')->default('light'),
]);
}
private function typographyCategory(): Category
{
return Category::make('typography')
->label(__('filamentcraft::filamentcraft.editor.settings.typography'))
->icon('heroicon-o-language')
->settings([
Font::make('heading_font')
->label('Display font')
->cssVar('--font-heading')
->default('serif'),
Font::make('default_font')
->label('Body font')
->cssVar('--font-default')
->default('sans'),
Range::make('body_size')
->label('Body size')
->cssVar('--font-body-size', 'px')
->unit('px')->min(15)->max(20)->step(1)->default(17),
Range::make('heading_scale')
->label('Heading scale')
->cssVar('--font-heading-scale')
->unit('')->min(1.1)->max(2)->step(0.05)->default(1.4),
]);
}
private function buttonsCategory(): Category
{
return Category::make('buttons')
->label(__('filamentcraft::filamentcraft.editor.settings.buttons'))
->icon('heroicon-o-cursor-arrow-rays')
->settings([
Range::make('button_radius')
->label('Corner radius')
->cssVar('--button-radius', 'px')
->unit('px')->min(0)->max(40)->step(2)->default(28),
// … button_padding_y / button_padding_x Range entries trimmed
Checkbox::make('button_uppercase')
->label('Uppercase labels')
->cssVar('--button-text-transform')
->cssValues('uppercase', 'none')
->default(false),
]);
}
private function layoutCategory(): Category
{
return Category::make('general_settings')
->label(__('filamentcraft::filamentcraft.editor.settings.general_settings'))
->icon('heroicon-o-cog-6-tooth')
->settings([
// … container_max_width / section_spacing Range entries trimmed
Select::make('header_layout')
->label('Header layout')
->options([
'centered' => 'Centered',
'logo-left' => 'Logo left',
'transparent' => 'Transparent',
])
->default('centered'),
]);
}
}What the setting types give you
ColorSchemeGroup— the whole color system in one line. It renders the scheme picker in the editor and emits the full--fc-color-*token set (surfaces, on-colors, OkLch shade ramps) for whichever scheme the site owner picks. See color schemes.Font— a curated font picker whose value flows to a CSS variable viacssVar().Range— numeric sliders. The secondcssVar()argument is the unit appended to the emitted value (->cssVar('--font-body-size', 'px')turns17into17px);heading_scaleomits it because a unitless multiplier is the correct CSS value.Checkbox+cssValues()— maps a boolean onto two concrete CSS values, sobutton_uppercasebecomes--button-text-transform: uppercase|noneinstead of leakingtrue/falseinto a stylesheet.SelectwithoutcssVar()—header_layoutis a plain data setting: no CSS variable, just a value your header section reads from the theme settings and branches on. Not every theme setting has to be a token.
Category labels reuse the package's own translation keys (filamentcraft::filamentcraft.editor.settings.*) so the theme panel stays consistent — and translated — alongside built-in themes.
The rest of the contract is optional
Only slug(), name(), and settingsSchema() are mandatory — AbstractTheme stubs every other ThemeContract method with a sensible default, so a theme overrides them only when it has something to say:
description()— the one-line blurb shown in admin UIs (Cascade overrides it above).previewImage()— a preview-image URL for the theme picker.stylesheets()/scripts()— extra asset URLs the theme declares for the rendered site, on top of the package-shippedfilamentcraft-sitebundle. Usestylesheets()when the theme ships CSS of its own instead of inlining everything through tokens.
Registration + sync
Themes are explicitly registered (unlike sections, there is no path auto-scan — a theme is a deliberate, site-wide choice). Two equivalent doors:
php
'themes' => [
'register' => [
\App\Theming\CascadeTheme::class,
],
],Or on the plugin, next to the rest of your panel configuration:
php
FilamentCraftPlugin::make()
->registerTheme(\App\Theming\CascadeTheme::class)(->registerThemes([...]) registers several at once.)
Then sync once per deploy:
bash
php artisan filamentcraft:sync-themesThe command ensures every registered theme class has a matching Theme database row, so it appears in the site's theme picker.
Expected result:
INFO Theme rows synced.
⇂ [exists] studio → Studio
⇂ [created] cascade → CascadeRe-running is always safe — it uses Theme::firstOrCreate() keyed on the slug — but for the same reason it does not update existing rows: rename name() in the class and the old name stays in the database until you update or delete the row (or change the slug, which creates a fresh row and orphans the old one).
How sections consume the result
Every cssVar() declaration is emitted into a <style id="fc-tokens"> block on :root of the rendered site (and live-patched in the editor preview as sliders move). Section views never know which theme is active — they just read the variables with fallbacks:
blade
<h2 style="font-family: var(--font-heading, var(--font-default, ui-sans-serif, system-ui, sans-serif));">blade
<a style="border-radius: var(--button-radius, 0.5rem);
text-transform: var(--button-text-transform, none);">Those lines are from the pricing section example — the same section renders as round, uppercase-buttoned Cascade or as any other theme purely through the token layer.
