Repository navigation
20 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.
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.
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.
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.
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.
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 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.
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-formis 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-accordionis not guarded. It is<details>and<summary>, which the platform renders correctly on their 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 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' };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,requiredadds the*marker through::before. A field with no label content hides its empty label, marker included, so it takes no row for it. Theful-sr-labelclass 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-onlyon 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-refattributes 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-noteclass on any element, sized by--ful-note-gapand--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-listandful-empty(the stand-in for content that is not there, in an empty table and an empty dropdown alike). -
ful-spinneris 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 submittingful-form, which gives its spinners that role and a label (see Forms). Four classes resize or place it:.centeredcenters it in a row of its own,.bigand.biggerdraw a 2rem and a 4rem glyph, and.backdropcovers the viewport with a translucent layer that takes the clicks, with a card in--ful-bgcentred on it holding a large glyph and, under it, the spinner's text, and while a backdrop spinner is nothiddenthe 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>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.
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project