Appearance
Example: custom pricing section
A complete, production-shaped custom section from the demo host app: a pricing grid with a monthly/yearly toggle, repeatable plan blocks, and a featured-plan highlight. It exercises most of the section API surface — the settings DSL, a Block with nested settings, defaults(), live-preview bindings, and theme-token-driven styling — in one file pair:
app/Sections/PricingSection.php— the section classresources/views/sections/pricing.blade.php— the Blade view
The section class
php
<?php
declare(strict_types=1);
namespace App\Sections;
use FilamentCraft\Sections\BladeSection;
use FilamentCraft\Sections\Block;
use FilamentCraft\Settings\Setting;
use FilamentCraft\Settings\Types\Checkbox;
use FilamentCraft\Settings\Types\Color;
use FilamentCraft\Settings\Types\ColorScheme;
use FilamentCraft\Settings\Types\Select;
use FilamentCraft\Settings\Types\Text;
use FilamentCraft\Settings\Types\Textarea;
final class PricingSection extends BladeSection
{
protected static ?string $previewImage = 'images/section-previews/pricing.png';
protected static string $view = 'sections.pricing';
public static function slug(): string
{
return 'pricing';
}
public static function name(): string
{
return 'Pricing';
}
public static function icon(): string
{
return 'heroicon-o-banknotes';
}
public static function wrapper(): string
{
return 'section.demo-pricing';
}
/**
* @return array<int, Setting>
*/
public static function settings(): array
{
return [
ColorScheme::make('scheme')->label('Color scheme'),
Text::make('title')->label('Title')->default('Pricing that scales with you'),
Textarea::make('subtitle')
->label('Subtitle')
->default('Pick the plan that fits today. Upgrade as you grow.'),
Select::make('billing')
->label('Default billing cycle')
->options([
'monthly' => 'Monthly',
'yearly' => 'Yearly (save 20%)',
])
->default('monthly'),
Text::make('monthly_label')->label('Monthly toggle label')->default('Monthly'),
Text::make('yearly_label')->label('Yearly toggle label')->default('Yearly · save 20%'),
Text::make('popular_label')->label('Featured badge label')->default('Most popular'),
Text::make('billing_note')
->label('Billing note')
->default('Yearly plans are billed annually. Cancel anytime.'),
Color::make('accent')->label('Accent color')->default('#6366f1'),
];
}
/**
* @return array<int, Block>
*/
public static function blocks(): array
{
return [
Block::make('plan')
->name('Plan')
->settings([
Text::make('name')->label('Plan name')->default('Starter'),
Text::make('tagline')->label('Tagline')->default('Everything you need to launch.'),
Text::make('currency')->label('Currency symbol')->default('$'),
Text::make('price')->label('Monthly price')->default('19'),
Text::make('yearly_price')->label('Yearly price (per month)')->default('15'),
Text::make('period')->label('Period')->default('/mo'),
Textarea::make('features')
->label('Features (one per line)')
->default("Unlimited pages\nLive editor\nEmail support"),
Checkbox::make('featured')->label('Highlight as featured')->default(false),
Text::make('cta_label')->label('CTA label')->default('Start trial'),
Text::make('cta_url')->label('CTA link')->default('#'),
]),
];
}
/**
* @return array{settings: array<string, mixed>, blocks: array<int, array<string, mixed>>}
*/
public static function defaults(): array
{
return [
'settings' => [
'title' => 'Pricing that scales with you',
'subtitle' => 'Pick the plan that fits today. Upgrade as you grow.',
'billing' => 'monthly',
'monthly_label' => 'Monthly',
'yearly_label' => 'Yearly · save 20%',
'popular_label' => 'Most popular',
'billing_note' => 'Yearly plans are billed annually. Cancel anytime.',
'accent' => '#6366f1',
],
'blocks' => [],
];
}
}Anatomy
slug()— the stable identifier stored in template revisions. Renaming the class is safe; changing the slug orphans existing content, so treat it as permanent.name()/icon()/$previewImage— what editors see in the section catalog.wrapper()— the tag/selector wrapping the rendered output (default'section').section.demo-pricingadds a stable class hook for host CSS without touching the view.$view— a plain Blade view in the host app's ownresources/views.BladeSectionis the right base class here because the section has no server-side state; the monthly/yearly toggle is a few lines of vanilla JS, so the live site stays Livewire-free.settings()— section-level settings using the input DSL.ColorScheme::make('scheme')gives editors the standard per-section scheme picker.blocks()— the repeatable unit. Each plan is aBlockwith its own nested settings, so editors add/reorder/remove plans in the sidebar rather than filling flatplan_one_name/plan_two_nameslots. Chain->limit(4)on theBlockto cap how many plans an editor can add; the section-wide ceiling is the overridablemaxBlocks()(default 16). Two more schema hooks this section leaves at their defaults:category()groups the section in the add-section catalog, andpresets()ships one-click pre-filled variants.defaults()— the content a fresh instance starts with. Blocks are intentionally empty here: the view renders an "Add a Plan block to get started" empty state, which nudges the editor toward the repeater instead of shipping placeholder plans.
The view
The view receives $section, whose settings is a value bag ($s->get('key', $fallback)) and whose blocks is the ordered list of plan blocks. Trimmed to the instructive parts — every line shown is verbatim from the demo:
blade
@php($s = $section->settings)
@php($scheme = $section->colorScheme())
@php($defaultBilling = $s->get('billing', 'monthly') === 'yearly' ? 'yearly' : 'monthly')
@php($rootId = 'demo-pricing-'.$section->id)
@php($activePill = 'background-color: var(--fc-color-surface); color: var(--fc-color-on-surface); box-shadow: 0 1px 2px rgba(0,0,0,0.08);')
<div id="{{ $rootId }}" class="demo-pricing px-6 py-20" data-billing="{{ $defaultBilling }}"
{!! $scheme->attributes() !!}
style="
background-color: var(--fc-color-background);
color: var(--fc-color-on-background);
font-family: var(--font-default, ui-sans-serif, system-ui, sans-serif);
font-size: var(--font-body-size, 1rem);
">
<div class="mx-auto max-w-3xl text-center">
<h2 {!! $section->liveUpdate('title') !!}
class="text-3xl font-bold sm:text-4xl"
style="font-family: var(--font-heading, var(--font-default, ui-sans-serif, system-ui, sans-serif)); color: var(--fc-color-on-background);">
{{ $s->get('title') }}
</h2>
<p {!! $section->liveUpdate('subtitle') !!}
class="mt-3 text-lg"
style="color: color-mix(in oklab, var(--fc-color-on-background) 75%, transparent);">
{{ $s->get('subtitle') }}
</p>
{{-- … monthly/yearly toggle pills (data-billing-target buttons) trimmed --}}
</div>
@if (count($section->blocks) > 0)
<div class="mx-auto mt-12 grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-3">
@foreach ($section->blocks as $block)
@php($featured = (bool) ($block->settings->get('highlighted') ?? $block->settings->get('featured')))
@php($features = preg_split('/\r?\n/', (string) $block->settings->get('features', '')) ?: [])
@php($currency = trim((string) $block->settings->get('currency', '$')))
@php($monthlyPrice = (string) $block->settings->get('price'))
@php($yearlyPrice = (string) $block->settings->get('yearly_price', $monthlyPrice))
@php($period = trim((string) $block->settings->get('period')))
@php($numeric = is_numeric($monthlyPrice))
@php($ctaUrl = (string) ($block->settings->get('url') ?: $block->settings->get('cta_url') ?: '#'))
{{-- … card shell, featured badge, plan name/tagline trimmed --}}
<ul class="mt-6 space-y-2 text-sm">
@foreach ($features as $feature)
@if (trim((string) $feature) !== '')
<li class="flex gap-2">
<span style="color: var(--fc-color-primary);">✓</span>
<span style="color: var(--fc-color-on-surface);">{{ $feature }}</span>
</li>
@endif
@endforeach
</ul>
{{-- … CTA button and card close trimmed --}}
@endforeach
</div>
@else
<p class="mt-12 text-center text-sm"
style="color: color-mix(in oklab, var(--fc-color-on-background) 60%, transparent);">
Add a Plan block to get started.
</p>
@endif
{{-- … billing note and toggle <script> trimmed --}}
</div>What to notice:
- Theme tokens, never hard-coded colors. Every color is a
--fc-*variable (--fc-color-background,--fc-color-surface,--fc-color-primary, …) and every typographic/shape choice reads a theme variable (--font-heading,--button-radius) with a sensible fallback. That's what makes one section render correctly under every theme and color scheme. $scheme->attributes()applies the editor-picked per-section color scheme by swapping the--fc-color-*values on this subtree.liveUpdate()bindings.{!! $section->liveUpdate('title') !!}(and$block->liveUpdate('name')on block fields) lets the editor patch text in place as you type instead of re-rendering the whole section. For attributes rather than text, chain->attr()— the CTA link uses{!! $block->liveUpdate('cta_url')->attr('href') !!}. The builder also offers->html()(rich-text values),->style('property')(patch one CSS property), and->toggleClass('class')(flip a class from a boolean setting).- The
highlighted/urlfallbacks. The block schema above declaresfeaturedandcta_url, yet the view also reads$block->settings->get('highlighted')andget('url'). That's demo-specific compatibility: this section'spricingslug shadows the built-in pricing section, whose blocks use those keys — so plans authored against the built-in keep rendering. If your slug is your own, read only your declared keys. - Derived values are computed defensively (
preg_spliton the features textarea,is_numericon the price) because settings arrive as editor-typed strings. - The toggle script is idempotent. The trimmed
<script>guards withroot.dataset.fcBoundbefore wiring click handlers, because the editor preview re-renders sections in place — without the guard, every morph would stack another listener.
Zero-config discovery
There is no registration step. The demo's config/filamentcraft.php leaves both hooks empty:
php
'sections' => [
'paths' => [],
'register' => [],
],The conventional app/Sections/ directory is auto-scanned, so dropping PricingSection.php there is enough for it to appear in the catalog. paths and register exist for sections that live elsewhere (a domain module, a shared package).
