Skip to content

20 Theming

Roberto Ferranti edited this page Nov 3, 2026 · 18 revisions

Theming

ful.css is the whole design system in one stylesheet, with no other stylesheet, icon font or javascript to load.

It rests on two ideas. Every visual decision hangs off a --ful-* custom property, and the styling hooks are the style-only tags the templates render, such as ful-control-group, ful-affix, ful-badge and ful-note.

What matters about the second idea is that the chrome matches the rendered structure, not a list of host tags. A component's css anchors on whatever hosts its pieces, as *:has(> ful-control-group) and *:has(> ful-toast) do. A subclass under any tag of your own therefore keeps the look, and so does foreign markup built from the same tags. The pre-render guards are tag-based, and so are a few rules keyed on a host itself: the menu's placement, the hiding of an empty toast region, the loading state of the sectioned components and the table's sort arrows.

The stylesheet also sets box-sizing: border-box on every element of the page, and on their ::before and ::after, in the ful.components layer, so a page rule outside a layer overrides it.

The tokens

Base palette, defined on :root:

Token Default Drives
--ful-color / --ful-bg light-dark(#212529, #dee2e6) / light-dark(#fff, #212529) text and surfaces
--ful-border-width / --ful-border-radius 1px / 0.375rem every border and corner
--ful-border-color light-dark(#dee2e6, #495057) resting borders
--ful-disabled-bg light-dark(#e9ecef, #343a40) disabled surfaces, scrollbar track
--ful-selected-bg light-dark(#f8f9fa, #343a40) selected rows, chips (ful-badge) and selected dropdown items. Checked radio cards carry their own --ful-radio-group-label-checked-bg
--ful-active-color / --ful-active-bg white / #007376 the accent: active options, buttons, pagination; --ful-active-color also paints the switch knob
--ful-accent-ink the accent in light; in dark, the accent mixed 45% toward white (55% accent, 45% white) the accent as text: ghost outlines, done steps, success toasts, hover chips
--ful-muted-color light-dark(#495057, #adb5bd) muted ink (resting tabs, quiet labels)
--ful-focus-border-color / --ful-focus-box-shadow derived from --ful-active-bg via color-mix the focus ring
--ful-invalid-color / --ful-invalid-border-color / --ful-invalid-box-shadow light-dark(#dc3545, #ea868f) + derived the invalid ring and error text
--ful-error-* / --ful-warning-* red / amber triads, light-dark() pairs like the rest ful-errors banner, ful-field-warning
--ful-dirty-mark-color #ffc107 the dot on the submit buttons of a form with unsaved edits, kept at 3:1 against --ful-active-bg
--ful-scrollbar-* width 12px, track --ful-disabled-bg, thumb --ful-active-bg dropdown scrollbar
--ful-controls-bg/-border-width/-border-radius/-border-color alias the base tokens the control chrome
--ful-controls-height calc(1.5em + 0.75rem + 2px) density: every control's height, by default the height of the native date and time inputs, so no control is shrunk to a shorter one
--ful-backdrop-color rgb(0 0 0 / 50%) the dialogs' and drawers' scrim

--ful-border-width, --ful-border-radius and the scrollbar widths are registered with @property (typed, inherited, with initial values), so they behave as real inherited custom properties everywhere.

Derived values are the point of the arrangement. The focus ring is color-mix(in srgb, var(--ful-active-bg) …) and the invalid ring mixes --ful-invalid-color, so retuning the accent moves every ring, glow and active surface with it. No colour is hardcoded where a token could carry it, though a few literals survive in inline svg strokes and the spinner backdrop.

Customizing

Override on :root for the page, or on any subtree to theme a region:

:root {
    --ful-active-bg: #6741d9;                 /* your accent: rings, actives, thumb follow */
    --ful-invalid-color: #b3261e;
    --ful-border-radius: 0;                   /* squarer */
    --ful-controls-height: 2.75rem;           /* denser or roomier, uniformly */
}

.sidebar {                    /* a dark pocket without a second theme */
    --ful-bg: #1c1f26;
    --ful-color: #e6e8ec;
    --ful-border-color: #333945;
}

Dark mode is light-dark(). The palette flips when the page declares color-scheme: dark, or light dark for automatic; the library declares nothing itself. Pinning color-scheme: light disables the dark side, per subtree included. If your brand defines explicit pairs instead, overriding the light-dark() tokens with plain values is equally fine.

The theme's ink pairs hold WCAG AA in both schemes, and a test suite computes the ratios on every run so they keep holding. Wherever the accent must be read rather than filled, use --ful-accent-ink: raw --ful-active-bg as text drops to 2.7:1 on the dark surface.

The overlay geometry

Three tokens size the overlays. The library declares none of them, reading each with its default as the fallback, so the page sets them wherever it likes: on :root for every overlay, or on one host, a class of its own included, for one of them.

token sizes default
--ful-dialog-width the width of a ful-dialog min(90vw, 32rem)
--ful-drawer-width the width of a ful-drawer min(90vw, 28rem), 100vw below 30rem of viewport
--ful-toasts-max-width the max width of the toasts region min(90vw, 24rem)
:root           { --ful-dialog-width: min(90vw, 44rem); }
ful-drawer.wide { --ful-drawer-width: min(90vw, 60rem); }
ful-toasts      { --ful-toasts-max-width: min(90vw, 30rem); }

Below 30rem of viewport the default drawer fills the screen, so a phone gets a full-width panel; a width the page sets holds there too, capped at the viewport, so set it inside a media query of your own to keep the phone's full width.

--ful-tabs-scroll-bg follows the same rule: the library reads it with --ful-bg as the fallback, to paint the fade under the scroll buttons of a ful-tabs whose tabs do not fit. Set it to the surface the tabs sit on when that is not --ful-bg.

Per-component namespaces

Components alias or extend the base tokens under their own prefixes, so one widget can be retuned without touching the system.

Prefix Component
--ful-dropdown-* the select dropdown: colors, border, scrollbar, radius
--ful-spinner-icon-* the spinner and a loading section's ring: size, border width, color, animation (--ful-spinner-icon-animation-name defaults to the ful-spinner-border keyframes)
--ful-radio-group-label-* radio card borders and the checked card's background
--ful-button-* the opt-in button chrome: height, borders, colors, hover pair
--ful-errors-color, --ful-errors-bg, --ful-errors-border-color a form's ful-errors banner, set on ful-errors
--ful-field-error-invalid-color a field's error text, set on ful-field-error
--ful-item-list-item-color, --ful-item-list-item-bg, --ful-item-list-item-border-radius the rows of a select's or a file field's item list, set on ful-item-list

The filter operator/sensitivity buttons keep a fixed min-width (4rem / 2.5rem) so a filter row keeps its width when the operator changes.

Your layers and [hidden]

ful's css is layered, and every layer a page declares of its own comes after ful's, so an unimportant declaration of yours beats any of ful's whatever the selectors say. That is the point, and it costs one thing worth knowing about.

[hidden] { display: none } lives in ful.hidden, the last of ful's layers, deliberately without !important so a page can still override it. The browser's own [hidden] rule loses to an author display in exactly the same way, so this is the platform's behaviour rather than ful's. But a component rule of yours that sets display defeats hidden on its own elements by accident:

@layer app { .pane { display: flex; } }
<div class="pane" hidden>this shows</div>

Rather than patching each component as the case comes up, declare the rule once:

@layer app {
    [hidden]:not([hidden="until-found"]) { display: none !important; }
}

An important declaration inside a layer beats every later layer and unlayered css alike, so one line settles it for the whole page. The :not() keeps hidden="until-found" working, which is findable-but-hidden content the browser reveals on a find-in-page.

The pre-render guards

The pre-render guards keep an element invisible until it has rendered, rather than letting it show markup the author never meant to be seen:

:is(
    ful-select,
    ful-dialog,
    /* … one entry per guarded tag … */
):not(:state(rendered)) {
    visibility: hidden;
}

rendered is a custom state the base adds when a render completes, and it is deliberately not :defined. :defined is true from the constructor, which runs before the element has built anything, so a guard written against it reveals an empty element and hides only the wait for the class to arrive. The state marks the moment the dom exists.

What is guarded, and what is not

The guard is for an element whose own markup is misleading before it renders. A ful-select paints its slotted <select> as a native dropdown. A ful-dialog shows its modal body inline in the page flow. A ful-tabs shows every panel at once and then collapses to one.

An element whose markup already reads correctly is left alone, and the two that matter are worth knowing:

  • ful-form is not guarded. Its fields guard themselves, so hiding the host would additionally hide the submit button and any plain markup around them, trading a partly drawn form for a completely blank one, for longer.
  • ful-accordion is not guarded. It is <details> and <summary>, which the platform renders correctly on their own.

Guarding a field of your own

The list is tags, so a field you register under your own tag is not in it. Two ways in:

<x-rating class="ful-input" name="score">How did it go?</x-rating>
x-rating:not(:state(rendered)) { visibility: hidden }

The class is the same hook the library uses for a field rendered under a foreign tag; the rule is the explicit form. Either works, since both key on the state the base sets for every ParsedElement.

An element whose render threw never gets the state, so it stays hidden rather than showing chrome it never built. The failure still reaches the console, Rendering.waitFor and your error reporter.

Icons

Icons are ful-icon elements: an empty inline box, 1em tall on a -0.125em baseline, painted with background-color: currentcolor behind an svg mask-image. They inherit text colour for free and scale with the font.

<ful-icon name="search" aria-hidden="true"></ful-icon>

Built-in shapes (from bootstrap-icons 1.11.3, MIT): search, chevron-left, chevron-right, chevron-down, arrow-clockwise, x-lg, info-circle, info-circle-fill (the tooltip's default, since the outline one goes noisy at 1em), upload, arrow-up, arrow-down, inbox, star.

Adding your own is one css rule per shape:

ful-icon[name='bell'] {
    mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='black' viewBox='0 0 16 16'><path d='…'/></svg>");
}

Where components pick icons it is static config, assignable before configure():

ful.Pagination.config = { ...ful.Pagination.config, reloadIcon: 'arrow-repeat' };
ful.Table.config = { ...ful.Table.config, searchIcon: 'magnifier' };

Structural hooks

Components render structure, classes are yours to add. The hooks that carry styling:

  • ful-control-group: the control's row. It rings on :has(:focus-visible), dims when disabled, and reddens when :invalid (see fields).
  • ful-affix: a cell inside the row, for icons, @ signs and buttons.
  • labels: the field's real <label>/<legend> is styled directly, required adds the * marker through ::before. A field with no label content hides its empty label, marker included, so it takes no row for it. The ful-sr-label class on a field hides its label visually while it stays the control's accessible name, for a field whose purpose the layout already shows, a search box in a toolbar or a checkbox in a table cell; the control moves up into the room the label leaves.
  • .ful-sr-only on any element hides it visually and leaves it in the accessibility tree, as ful does for the spinner's localized label.
  • internal parts also carry data-ref attributes such as [data-ref=dropzone] and [data-ref=value1]. They are handy for targeted overrides, but they are the components' internals rather than a promised api, so prefer the tokens and the tags.
  • the disclosure and navigation family adds its own: ful-note (the tooltip bubble, or its .ful-note class on any element, sized by --ful-note-gap and --ful-note-callout-size), ful-toast (a toast item, whoever hosts them being the region), ful-tablist, ful-accordion-group, ful-steps, ful-table-wrapper, ful-pagination-bar, ful-radio-list and ful-empty (the stand-in for content that is not there, in an empty table and an empty dropdown alike).
  • ful-spinner is a style-only tag entirely: the glyph is its own pseudo-element, the label its light-dom children, role="status" (and any name) the author's to carry, except inside a submitting ful-form, which gives its spinners that role and a label (see Forms). Four classes resize or place it: .centered centers it in a row of its own, .big and .bigger draw a 2rem and a 4rem glyph, and .backdrop covers the viewport with a translucent layer that takes the clicks, with a card in --ful-bg centred on it holding a large glyph and, under it, the spinner's text, and while a backdrop spinner is not hidden the page behind it does not scroll.

A ful-item-list row is as tall as a control, so a readonly field showing nothing but its list lines up with an ordinary input beside it. The height follows --ful-controls-height and --ful-controls-border-width, so a denser theme takes the rows with it. A focused row is outlined in --ful-active-bg rather than wearing --ful-focus-box-shadow, the one place the library departs from that ring: the row is filled with the accent, and a translucent ring of the same accent would be hard to see against it.

.ful-stacked, on a ful-checkbox, lays the choice out the way every other field is laid out: the label above and the control below it, a control tall, so a checkbox or a switch in a column of inputs lines up with them instead of sitting on one short line of its own. The control is drawn a little larger there, having no box around it to give it presence. Without the class the label stays beside the control, which is the right shape for a checkbox on a line of its own.

<ful-checkbox class="ful-stacked" name="urgent" type="switch">Urgente</ful-checkbox>

Buttons

The opt-in class chrome is .ful-button, with its .ghost, .soft and .small modifiers, which apply only alongside it:

class look
.ful-button a filled primary resting on the accent pair
.ful-button.ghost an accent outline that fills on hover
.ful-button.soft a neutral fill that turns accent on intent
.ful-button.small a shorter button in a smaller font

All of them theme through --ful-button-* and carry the canonical focus halo and disabled dimming. Any button or a wears them. Anchors have no :disabled, so aria-disabled="true" is what dims those.

When building your own components, reuse ful-control-group and ful-affix in your template: the chrome, focus and disabled styling arrive for free, and your control stays themeable by the same tokens.

Clone this wiki locally