A surface is anything that has a look: a button, a field, a balloon, a plain div.
The surface engine paints all of them in one place, from a small set of values called tokens.
fill-primary, content-accent, rise-2...The engine is a preview and it is opt-in: it paints only inside .pacem-surfaces.
Everywhere else the standard theme is in charge. This site opts in on its body.
<body class="pacem-surfaces">
...
</body>
Every surface plays three roles. Often one element plays all of them.
| Role | Job | |---|---| | host | holds the visual states and reads the tokens | | face | paints the box: background, border, shadows, outline | | content | paints the ink: text color, text shadow |
A button is all three. A field is the host, its inner input is the face.
A menu is the host, its nav is the face.
You write tokens on the host. The engine turns them into slots, and the face paints the slots.
host: --pacem-surface-background-color (token: you write it)
↓
--pacem-slot-background-color (slot: the engine's, for the current state)
↓
face: background-color
Rules are ordered state by state. A later state always wins, whatever the specificity:
default → focus → hover → active → selected → readonly → disabled
Inside each state, from generic to specific:
engine → kinds → variants → utilities → app
fill-*, rise-*...)So a disabled fill-primary button is gray: disabled comes after default.
Traits are the features of a theme that are on or off: shadows, gradients, glows...
Each theme starts from the traits of its base and can override them in pacem-themes.json.
| Trait | Toggles | dark | light |
|---|---|:---:|:---:|
| shadows | drop shadows | ✓ | |
| gradients | glossy gradients | ✓ | |
| glows | glowing hovers | | |
| blur | backdrop blur | | |
| dark-scheme | dark color scheme | ✓ | |
| field-underline | underlined fields (boxed otherwise) | ✓ | |
| field-outline | focus outline on every field | | |
In css, a trait is a zero-specificity selector: :--name when on, :--no-name when off.
@layer surfaces.default {
:--shadows .my-card { --pacem-surface-shadow-elevation: var(--pacem-rise-2); }
:--no-shadows .my-card { --pacem-surface-border-width: 1px; --pacem-surface-border-style: solid; }
}
Any other name is a custom trait, off unless a theme turns it on.
npx pacem-theme-css traits lists them all.
One token per painted property: --pacem-surface-{property}.
The box
| Token | Paints |
|---|---|
| background-color | the background |
| background-image-1, -2, -3 | up to three background images |
| border-width, border-style, border-color, border-radius | the border |
| border-image | a frame (see below) |
| shadow-elevation, shadow-glow, shadow-inset | the three entries of box-shadow |
| outline-color | the outline |
The ink
| Token | Paints |
|---|---|
| color | the text color |
| text-shadow | the text shadow |
Size, spacing, motion
| Token | Paints |
|---|---|
| width, height, aspect-ratio | the size |
| margin-top, -right, -bottom, -left | the margins |
| padding-top, -right, -bottom, -left | the paddings |
| duration-background, -color, -border, -shadow, -outline, easing | the transitions |
Some properties are lists made of several tokens: box-shadow (elevation, glow, inset), background-image
(1, 2, 3), transition (the durations). The first token of a list must be set, or the whole list keeps its
standard look. A ring alone, for example, needs a neutral elevation too: --pacem-surface-shadow-elevation: 0 0 #0000.
A visual state has no tokens of its own: it is a rule that changes the tokens it needs. What it doesn't change keeps its resting value.
| State | Selector | When |
|---|---|---|
| focus | :--focused | focused (never when disabled) |
| hover | :--hovered | hovered (never when disabled or read-only) |
| active | :--pressed | pressed (never when disabled or read-only) |
| selected | :--selected | .pacem-selected on a togglable host, or :checked |
| read-only | :--readonly | .pacem-readonly, or [readonly] |
| disabled | :--disabled | .pacem-disabled, or :disabled |
Each state rule goes in its own layer. In an embedded file:
@layer surfaces.default {
.my-button { --pacem-surface-background-color: var(--pacem-color-info); }
}
@layer surfaces.hover {
.my-button:--hovered { --pacem-surface-background-color: var(--pacem-color-warning); }
}
Below, the same rules in a plain stylesheet. The disabled button stays gray: the button's own disabled rule comes later.
Generic classes that set tokens. They win over what a component sets, in the same state.
Color
| Class | Sets |
|---|---|
| fill-{color}, fill-{color}-container | the background, and a matching ink |
| content-{color} | the ink (it overrides a fill's ink) |
| halo-{color} | a 2px ring (with an elevation: add a rise-*, or it shows the standard glow) |
| fill-none | no background, in any state |
Color utilities change on hover, focus and press only when the host asks for it:
fill-interactive (or content-, halo-interactive), pacem-hoverable, pacem-focusable, pacem-clickable.
Buttons always do.
Shape and size
| Class | Sets |
|---|---|
| shape-circle, shape-square | a 1:1 box (the circle rounded) |
| shape-ellipse, shape-rect | a free ratio, or ellipse-ratio-* / rect-ratio-* (16-9, 4-3...) |
| size-1 ... size-12 | the width, on the size scale |
| size--1 ... size--12 | the width, in pixels |
Depth
| Class | Sets |
|---|---|
| rise-1 ... rise-6 | a drop shadow (rise--1 ... rise--6: finer steps) |
| sink-1 ... sink-6 | an inner shadow (sink--1 ... sink--6: finer steps) |
Borders
| Class | Sets |
|---|---|
| border-none | no border at all |
| border-size-0 ... border-size-12 | the border width (border-size--N: N pixels) |
| border-rise | a bevel lit from above (with border-{color}, in that tint) |
The border width doesn't decide which sides show: the component's border styles do.
--pacem-surface-border-image takes any image, a gradient or a url(), and paints it in the border.
Unlike a plain border-image, a frame follows the radius.
.framed {
--pacem-surface-border-width: 2px;
--pacem-surface-border-radius: 12px;
--pacem-surface-border-image: linear-gradient(to right, var(--pacem-color-primary), var(--pacem-color-accent));
}
none means no frame.border-image.The buttons' own gradient border (in themes with gradients) is a frame too.
In an embedded file (the embed files of pacem-themes.json), wrap them in a state block:
@layer surfaces.default { ... }
@layer surfaces.hover { ... }
The states are default, focus, hover, active, selected, readonly, disabled.
Inside, you can use the state selectors (:--hovered...) and the theme traits (:--shadows...).
In a stylesheet of its own (no build), name the layer in full and write the states out:
@layer pacem.surfaces.hover.app {
.x:hover:not(.pacem-disabled, :disabled, .pacem-readonly) { ... }
}
The demos on this page are written this way.
pacem-input-text, not its input.--pacem-surface-*, never --pacem-slot-*.none.The demo shows rules 1 and 3.
| You see | Do this |
|---|---|
| the border doesn't show | set a border style, or a width with a frame |
| a token on an inner element does nothing | move it to the host |
| unknown :--x: a local alias of ... | select the element itself, or [surface-modifier] |
| "@layer ..." is not a surfaces block | write @layer surfaces.{state} { ... } |
| your rule is lost on hover or when disabled | repeat the token in that state's block |
| your hover rule fires when disabled | use :--hovered, not :hover |
A rounded gradient border on the active link:
@layer surfaces.default {
pacem-a.active {
--pacem-surface-padding-top: var(--pacem-size-1);
--pacem-surface-padding-bottom: var(--pacem-size-1);
--pacem-surface-border-radius: var(--pacem-size-2);
--pacem-surface-border-width: 1px;
--pacem-surface-border-image: linear-gradient(to right, red, purple);
}
}
A gleam, an image layer over the surface's color:
@layer surfaces.default {
.gleam:where([surface-modifier], .surface) {
--pacem-surface-background-image-1: linear-gradient(160deg, rgb(255 255 255 / 25%), rgb(255 255 255 / 0%) 60%);
}
}
Translucent stops let any fill show through; opaque ones cover it.
Components are surfaces on their own: their decorator marks them, nothing to write. Put the utilities on the component; it paints its face.
| Component | Face | Notes |
|---|---|---|
| pacem-button | itself | fill-* fills it |
| pacem-a | itself | a link: content-* recolors it, fill-* boxes it |
| pacem-input-*, pacem-textarea, pacem-select... | the inner input | |
| pacem-checkbox, pacem-radio | the label | fill-* is the "on" color |
| pacem-slider, pacem-progressbar | the track | content-* colors the progress |
| pacem-loader | the spinner | content-* the arc, fill-* the ring |
| pacem-balloon | its body and tail | |
| pacem-lightbox, dialogs | the container | |
| pacem-sidebar, pacem-menu | the drawer | |
| pacem-toast | itself | |
| pacem-icon | itself | content-* colors the glyph, fill-* boxes it |
Plain markup opts in with a class: surface (no look of its own), surface-button, surface-field.
To provide the best experiences, we use technologies like cookies to store and/or access device information. Consenting to these technologies will allow us to process data such as browsing behavior or unique IDs on this site. Not consenting or withdrawing consent, may adversely affect certain features and functions.