Skip to content

Styling sections and blocks ​

Each section's settings sidebar carries a Styling group with padding, margin (per viewport), background color, min-height and alignment. Block edit forms carry the same group with padding, margin, background color and max-width.

On sections, the styling fields sit behind a "Customize styling" switch (progressive disclosure): everyday editors only see the style-preset dropdown; flipping the switch reveals the full fields, prefilled from the selected preset. While the switch is off, the styling subtree is dropped on save so a later preset change never fights stale values.

These fields land in JSON under settings.styling for sections and data.styling for blocks. They are stored as-is — no DB migration; existing content keeps working untouched (sections saved before the switch existed are treated as customized).

Color palette ​

Background colors use PaletteColorType: a row of swatches, one per named project color, plus None and a Custom… swatch revealing a free color picker (the name of each color shows on hover). It stores the colour as declared — a plain #hex from the picker, or whatever the palette entry says ('' for none). Declare the palette in config:

yaml
# config/packages/content_blocks.yaml
content_blocks:
    palette:
        - { label: 'Primary', color: '#eb0540' }
        - { label: 'Dark',    color: '#252525' }

…or implement ContentBlocks\Palette\ColorPaletteProviderInterface (autoconfigured) for runtime palettes; both sources merge. With no palette declared, the field still offers None / Custom… — which is what gives the field a real empty state (backgrounds now default to transparent; the old #ffffff pre-fill hack is gone).

Upgrade caveat

Because backgrounds now default to transparent (''), an existing #ffffff value already persisted will render a real white background. Watch for this when upgrading.

PaletteColorType is reusable in your own block forms (option allow_custom: false locks editors to the palette).

Theme variables in the palette ​

A palette entry can name a CSS custom property of your theme, so a page follows the theme instead of freezing a hex in its content. Give it a hex fallback:

yaml
content_blocks:
    palette:
        - { label: 'Primary', color: 'var(--theme-primary, #eb0540)' }

The value is stored and rendered as written: style="color: var(--theme-primary, #eb0540)", so changing --theme-primary recolours every page that uses it, and a page imported into a site without the variable falls back to the hex.

The fallback is the colour's swatch: what it is shown and judged with everywhere the theme's CSS is not loaded, or where a real colour has to be computed (ColorPaletteRegistry::swatch()):

WhereUses
Sidebar swatch (data-color on the radio)the swatch
Dark / light tone classes, cb_color_tone()the swatch's luminance
Section library thumbnailsthe swatch
cb_color_palette()a swatch key beside color
CKEditor swatchesthe variable, written as is into the text
TinyMCE swatchesthe swatch — TinyMCE's color_map only takes hex

A bare var(--theme-primary) with no swatch is accepted too: the sidebar shows its name instead of a colour, and it gets no tone class, no thumbnail colour, no TinyMCE swatch. Give it a fallback, or a swatch as below.

Removing an entry from the palette does not reset the content using it: the field keeps offering that value as its own choice, shown by name, until the editor picks another. A copy pasted from the clipboard is judged against the palette, so there the value goes back to the default.

Colours from a back office ​

When the colours are edited in your own admin, a fallback written into the value would freeze in every block that uses it: change the colour, and stored content no longer matches the palette and keeps the old tone. Store the bare variable instead, and hand the current hex as the entry's swatch:

php
use ContentBlocks\Palette\ColorPaletteProviderInterface;
use ContentBlocks\Palette\PaletteColor;

final class ThemePaletteProvider implements ColorPaletteProviderInterface
{
    public function __construct(private readonly ThemeRepository $themes)
    {
    }

    public function getColors(): iterable
    {
        $theme = $this->themes->current();

        yield new PaletteColor('Primary', 'var(--brand)', swatch: $theme->primaryHex);
    }
}

Autoconfigured, nothing else to wire. The content stores var(--brand) and never changes; your public layout declares --brand from the same setting, so the page follows it, and the builder's preview, being that page, does too. The swatch drives everything the table above lists, so the sidebar, the tone classes and the thumbnails follow the back office as well, with no CSS to add to the builder. A config entry takes the same key: { label: 'Primary', color: 'var(--brand)', swatch: '#0f766e' }.

The palette is read once per request (ColorPaletteRegistry is reset between requests, worker mode included), so the provider may query its database. A swatch that is not a #rgb / #rrggbb hex is ignored. The sandbox's BackOfficePaletteProvider is a runnable example.

Section style presets ​

Presets are named styles offered in the section sidebar. Each carries a CSS class and/or settings values applied underneath the section's own settings at render time (the user's explicit values win key-by-key):

yaml
content_blocks:
    section_styles:
        - name: boxed
          label: 'Boxed'
          css_class: 'my-section--boxed'
          settings:
              styling:
                  backgroundColor: '#f1f5f9'
                  padding: { desktop: { top: 40, right: 40, bottom: 40, left: 40 } }
        - name: airy            # settings-only preset (no class)
          label: 'Airy'
          settings:
              styling: { padding: { desktop: { top: 96, bottom: 96 } } }

…or implement ContentBlocks\Section\SectionStyleProviderInterface and return SectionStyle instances (the fourth constructor arg is the settings array).

A builder without styling fields ​

For a sober builder, where the design system decides spacing and colours and editors only write content, turn the free styling fields off:

yaml
content_blocks:
    styling: false              # both sidebars
    # or one at a time:
    # styling: { block: false, section: true }
  • Block sidebar: the Style tab is gone. A block whose fields share one tab shows no tab bar at all.
  • Section sidebar: the Customize styling switch and the fields behind it are gone. The style preset dropdown stays, so the presets you declared in section_styles become the only way to style a section. Without any preset, the Style tab disappears.

To narrow the sections and columns too, see Simplifying the builder. The setting only changes the sidebars. Styling values already stored keep rendering, and a save from a sidebar without the fields leaves them untouched. The preset settings, initial_settings and content from code still apply. To start from clean content, clear styling in the data yourself.

Conditional form fields (cb-condition) ​

The sidebar's show/hide logic is a generic Stimulus controller you can reuse in your own block forms: attach data-controller="cb-condition" on a container (form type attr) and tag rows with row_attr → data-cb-condition="field:value1|value2" (checkboxes match true/false; field alone means "non-empty"). Combine conditions with AND by separating clauses with ; (e.g. size:custom;customHeightAuto:false); each clause still ORs its values with |. Separate whole alternatives with ||: display:slider||displayMobile:slider shows the row when either group matches. The field name matches the last bracket segment of the input's name.

At render time, two decorators (StylingSectionDecorator, StylingBlockDecorator) translate the values into CSS custom properties on the outer element, and a stylesheet shipped at /_content-blocks/public/styling maps those vars to real properties with @media rules for tablet (max-width: 991px) and mobile (max-width: 575px) — so per-viewport overrides actually work (inline style can't carry media queries).

The fallback chain inside each @media block is: mobile → tablet → desktop → 0. A viewport you leave blank inherits the next-wider one.

Background image of a section ​

With Customize styling on, a section's Style group has a background image (upload, drop or pasted path, like the image block). Once it is set, four more fields show:

  • Image size: cover (crops, the default) or contain.
  • Image position: center (the default), top, bottom, left or right.
  • Veil color and veil opacity (0 to 90%): a layer between the image and the content, to keep text readable over a photo. Black when no colour is picked.

The section gets cb-section--bg-image and the variables --cb-s-bg-img, --cb-s-bg-size, --cb-s-bg-pos, --cb-s-overlay and --cb-s-overlay-color. The image URL goes through your ImageUrlResolverInterface at 1920px, so a CDN or LiipImagine alias serves a resized file with no template to override. The file is referenced like any other: export/import embeds it and content-blocks:assets:gc keeps it.

The tone class follows the veil: from 40% opacity the section is cb-section--bg-dark or -light after the veil's colour. Under a fainter veil the photo decides, so no tone class is set.

Text alignment and max width of a block ​

A block's Style tab has three layout fields besides spacing and colour:

  • Text align (left, centre, right, justify) adds cb-block--text-start, -center, -end or -justify to the block. A class rather than a variable, so a block nested in another one does not inherit it.
  • Max width caps the block in pixels. It only ever shrinks the block, so a narrow screen still gets the full column, which side padding does not.
  • Horizontal align places a block narrower than its column. It appears once a max width is set.

Together they give readable line lengths without spacer columns: a text block with Max width: 720 and Horizontal align: center sits in the middle of a full-width section.

Dark and light backgrounds ​

When a section or a block has a background colour (or, for a section, an image under a dense enough veil, see above), its decorator also adds a tone class: cb-section--bg-dark / cb-section--bg-light, and cb-block--bg-dark / cb-block--bg-light. The package does not change the text colour itself, since that would restyle existing pages. Your stylesheet decides:

css
.cb-section--bg-dark,
.cb-block--bg-dark { color: #fff; }
.cb-section--bg-dark a { color: inherit; }

The tone comes from the colour's perceived luminance, with a threshold of 0.55 (ColorTone::LIGHT_THRESHOLD), the same rule the section library's thumbnails use. The same helper is available to your own code:

  • PHP: ContentBlocks\Palette\ColorTone::of('#1e293b') returns 'dark', 'light' or null for anything that is not #rgb / #rrggbb or a var() with such a fallback. isDark(), isLight() and luminance() are there too.

  • Twig: cb_color_tone(color) and cb_color_is_dark(color), for instance in a block template:

    twig
    <div class="hero hero--{{ cb_color_tone(data.color) ?? 'none' }}">

Extending the Styling sub-form ​

The StylingType form holds the styling fields. Register a Symfony FormTypeExtension against it to inject (or override, by re-add()ing an existing name) fields without forking — they will render inside the sidebar's Styling group, for sections and blocks alike:

php
use ContentBlocks\Form\Type\Styling\StylingType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;

final class ZIndexExtension extends AbstractTypeExtension
{
    public static function getExtendedTypes(): iterable
    {
        return [StylingType::class];
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // `include_gap` is only true for sections — use it to gate
        // section-only fields.
        if ($options['include_gap']) {
            $builder->add('zIndex', IntegerType::class, ['required' => false]);
        }
    }
}

Pair it with a SectionDecoratorInterface reading $settings['styling']['zIndex'] to emit the style. (For curated background colors, prefer the built-in palette config above.)

The styling fields are folded into collapsible panels (Spacing, Background, Size and alignment). Give your field 'cb_panel' => StylingType::PANEL_LAYOUT to join one, or a label of your own to open another; see Laying out sidebar fields.

Adding your own block decorator ​

Implement ContentBlocks\Block\BlockDecoratorInterface (mirror of SectionDecoratorInterface). It is auto-tagged with content_blocks.block_decorator when autoconfigure: true is on, and called for every block being rendered. Return a BlockDecoration (classes / inline styles / attributes) — the bundle merges all decorators' output into the block's outer <div>.

Overriding the package CSS: cascade layers ​

The public stylesheets (layout, styling and the kit's kit.css) use compound selectors, so a host rule usually has to out-specify them to win. css_layer serves all three inside a cascade layer:

yaml
# config/packages/content_blocks.yaml
content_blocks:
    css_layer: content-blocks

Every rule outside a layer then wins over the package's, whatever its specificity: h2 { font-weight: 300 } beats the kit's .cb-kit-title. To place the package among layers of your own, declare the order once, before any stylesheet:

css
@layer reset, content-blocks, theme;

It is off by default, and that is deliberate. A host's existing global CSS (img, a, a reset) wins over the package as soon as the option is on, so a page can change without a line of yours changing. Turn it on, then check your pages. The default changes only in 2.0.

  • The URLs cb_kit_stylesheet_url() and the content template link carry the layered file's version, so a browser cache never keeps the unlayered one.
  • The builder's own chrome is never layered: it has to win over your page CSS in the preview.
  • A dotted name places the package in a sub-layer: vendor.content-blocks.

Theming the builder chrome ​

Everything above styles content. The builder's own UI — topbar, sidebars, form widgets, preview overlay — is styled separately, from a single set of design tokens in assets/styles/tokens.css. Every rule in admin.css, forms.css and builder.css reads var(--cb-*) rather than a color literal, so restyling the tool is a matter of redeclaring tokens:

css
/* your admin stylesheet, loaded after the package's */
.cb-shell,
.cb-launcher,
.cb-builder-dialog {
    --cb-accent-rgb: 124, 58, 237;   /* drives the accent and all its alpha variants */
    --cb-accent-strong: #6d28d9;
    --cb-radius: 10px;
}

The token names are public surface — they are covered by the package's semver guarantee like any other API.

GroupTokens
Surfaces--cb-surface, --cb-panel, --cb-panel-2, --cb-panel-3, --cb-field-bg
Lines--cb-line, --cb-line-strong, --cb-line-hover
Text--cb-text, --cb-text-2, --cb-muted, --cb-faint, --cb-on-solid, --cb-on-solid-rgb, --cb-inverse-surface
Accent--cb-accent-rgb, --cb-accent, --cb-accent-strong, --cb-accent-darker, --cb-accent-soft, --cb-accent-soft-rgb, --cb-accent-bg
Status--cb-danger, --cb-danger-rgb, --cb-danger-strong, --cb-success, --cb-success-rgb, --cb-success-strong, --cb-warning, --cb-badge-bg, --cb-badge-fg
Shadow--cb-shadow-rgb; --cb-shadow-field (inputs, selects, checks), --cb-shadow-control, --cb-shadow-control-hover, --cb-shadow-selected (buttons at rest, under the pointer, picked)
Type--cb-font-mono, --cb-caption-size, --cb-caption-size-sm, --cb-caption-tracking
Geometry--cb-radius, --cb-radius-sm, --cb-radius-xs
Layout--cb-sidebar-width (340px), --cb-sidebar-collapsed-width (32px)

The form alias layer ​

forms.css declares a second, narrower set that points at the tokens above. Redeclare only these to make the builder's inputs differ from the rest of the chrome — useful when the host's admin has a strong form style of its own and you would rather not fight it.

GroupTokens
Color--cb-form-bg, --cb-form-bg-soft, --cb-form-bg-disabled, --cb-form-text, --cb-form-text-muted, --cb-form-placeholder, --cb-form-accent
Borders & focus--cb-form-border, --cb-form-border-hover, --cb-form-border-focus, --cb-form-focus-ring
Status--cb-form-danger, --cb-form-danger-ring, --cb-form-success
Type--cb-form-font-size, --cb-form-font-size-sm, --cb-form-font-size-xs, --cb-form-line-height, --cb-form-label-color
Metrics--cb-form-control-height, --cb-form-gap, --cb-form-radius, --cb-form-radius-sm

The label typography tokens used in the sentence-case recipe below — --cb-form-label-transform, --cb-form-label-font, --cb-form-label-size, --cb-form-label-tracking — belong to this layer too.

Three details worth knowing:

  • Alpha variants come from the *-rgb tokens. Rules build translucency with rgba(var(--cb-accent-rgb), 0.25), so overriding --cb-accent-rgb moves the solid color and every focus ring, hover tint and shadow that derives from it. Override --cb-accent alone and the alphas stay behind.

  • The --cb-form-* layer is an alias layer, not a second palette. Each token defaults to one of the chrome tokens above, so overriding --cb-accent-rgb alone already moves the form accent with it. Reach for the form tokens when you want the inputs to diverge from the chrome, not to restate it.

  • Captions are the only text the builder sets in its own font. Field labels, group titles and the small hints inside a control render in --cb-font-mono, uppercase — a label names a control, and the difference in case and family is what lets the eye skip them when scanning a settings column for a value. Body text and headings inherit the host's font.

    The caption scale is two sizes, not three. A group heading is the same size as the field labels under it and outranks them by weight and color (700 / --cb-text-2 against 500 / --cb-muted); --cb-caption-size-sm is reserved for captions subordinate to a single control, like the side names in the box-spacing grid. Stacking a third size on top of uppercase + mono + tracking only makes every caption shout at a slightly different volume.

    Checkbox and radio labels are deliberately left in the body font: those are the sentence the user reads to decide, not a name for a control. A standalone checkbox therefore renders without a row-level label, since it would repeat that sentence as a caption — while a radio group keeps its row label, which names the group rather than an option.

    To put labels back in sentence case:

    css
    .cb-shell {
        --cb-form-label-transform: none;
        --cb-form-label-font: inherit;
        --cb-form-label-size: 0.8125rem;
        --cb-form-label-tracking: normal;
    }

Reskinning the admin never restyles content

The preview iframe renders the host's public page with the kit's stylesheet. Its colors come from content_blocks.palette and the block styling settings documented above — a separate system on purpose, so changing the tool's accent never moves a button on the published page.

A handful of colors live inside data: URIs (the select chevron, the checkbox tick) because custom properties cannot reach inside one. They are commented in place; adjust them by hand if a reskin moves far from the shipped palette.

Released under the MIT License.