Search
Search
CSS custom properties and design tokens
hugolify-theme-design-system uses CSS custom properties (design tokens) instead of SASS variables. Tokens are plain CSS, consumable by any tool (Figma, Tokens Studio, style dictionaries).
The module does not ship its own reset or token system. It composes four framework-agnostic npm packages and adds the Hugolify-specific layer on top.
| Package | Contents | Layers |
|---|---|---|
| @uncinq/design-tokens | Primitive + semantic custom properties (color, size, typography, spacing…) | tokens |
| @uncinq/component-tokens | Component-scoped custom properties (--btn-*, --card-*, --container-*…) | tokens |
| @uncinq/css-base | Reset, native element styles, layout primitives (container, grid, row), @custom-media breakpoints | reset, base, layouts |
| @uncinq/css-components | Generic UI components (alert, badge, button, card, form, nav, panel…) | components |
Each package is strictly additive: no package reaches into a lower layer. They are installed by hugo mod npm pack, see PostCSS.
hugolify-theme-design-system itself is a Hugo module, not an npm package: it is imported through module.yaml and brings the Hugolify-specific layer (tokens, layouts, components, blocks, pages and utilities), writing into every layer above.
Import order matters: @import '@uncinq/css-base' must come before any @import that inlines local CSS in the same file, because postcss-import resolves npm package imports only in that position.
Tokens follow a three-level chain: primitive → semantic → component.
primitive/color.css → raw values (oklch…)
semantic/color.css → purposeful aliases (--color-brand, --color-danger…)
components/button.css → scoped tokens (--btn-color-background, --btn-border-radius…)
Never skip two levels: a component token should reference a semantic token, not a primitive directly — unless the primitive has no semantic meaning (e.g. --radius-none).
All tokens and styles are assigned to a named layer. The order is declared once, at the very top of assets/css/main.css, before any @import — CSS fixes a layer’s position the first time its name is seen.
@layer reset, tokens, vendors, base, layouts, components, pages, utilities;
| Layer | Content | Provided by |
|---|---|---|
reset | CSS reset | @uncinq/css-base |
tokens | All custom properties (primitive, semantic, component) | @uncinq/design-tokens, @uncinq/component-tokens, module |
vendors | Third-party libraries (Leaflet, Splide) | module |
base | Native element styles (body, headings, links, tables, forms…) | @uncinq/css-base |
layouts | Layout primitives (container, grid, row) + hugolify layouts | @uncinq/css-base, module |
components | UI components (.alert, .btn, .card…) and hugolify components | @uncinq/css-components, module |
pages | Page-specific rules | module |
utilities | Single-purpose classes — they win over a component’s own rules | module |
The last layer wins. reset and tokens come first (lowest priority) so token defaults never override base, layout or component styles.
Custom media queries are not layered: @custom-media is resolved at build time by PostCSS.
Breakpoints are @custom-media rules from @uncinq/css-base, named as rungs on a ladder rather than devices.
| Custom media | Width | Deprecated alias |
|---|---|---|
--sm | >= 48rem (768px) | --tablet |
--md | >= 64rem (1024px) | --tablet-wide |
--lg | >= 90rem (1440px) | --laptop |
--xl | >= 100rem (1600px) | --desktop |
@media (--md) {
.card { --card-padding-inline: var(--spacing-lg); }
}
The device names still work but are aliases kept for compatibility — prefer --sm/--md/--lg/--xl in new code.
Components that own a panel (header, filters, sidebar menu, table of contents) get their own semantic custom media, generated in main.css from site params — so the breakpoint and the .panel-inline-* classes in the markup cannot drift:
# /config/_default/params.yaml
header:
expand: md # md by default, none = always an overlay
Heading font sizes are fluid: each level references a clamp() token from the semantic scale, so no media query is involved.
/* @uncinq/design-tokens — semantic/typography.css */
--font-size-fluid-xl: clamp(1.5rem, 0.9718rem + 2.2535vw, 3rem);
--font-size-heading-01: var(--font-size-fluid-xl);
--font-size-heading-02: var(--font-size-fluid-lg);
/* @uncinq/css-base — base/headings.css */
h1 { --font-size-heading: var(--font-size-heading-01); }
To change a heading size, redefine the token — not the selector:
/* assets/css/tokens/site.css */
@layer tokens {
:root {
--font-size-heading-01: var(--font-size-fluid-2xl);
}
}
Some component tokens change at breakpoints. The breakpoint logic lives in the component CSS, not in the token file: token files only hold static values, and the media query just reassigns the token the component reads.
/* @uncinq/component-tokens — components/container.css — static values */
--container-max-width-tablet: 100%;
--container-max-width-tablet-wide: 100%;
--container-max-width-laptop: 100%;
--container-max-width-desktop: var(--size-desktop);
/* @uncinq/css-base — layouts/container.css — responsive logic */
.container {
max-width: var(--container-max-width, var(--container-max-width-mobile));
@media (--tablet) { --container-max-width: var(--container-max-width-tablet); }
@media (--tablet-wide) { --container-max-width: var(--container-max-width-tablet-wide); }
@media (--laptop) { --container-max-width: var(--container-max-width-laptop); }
@media (--desktop) { --container-max-width: var(--container-max-width-desktop); }
}
Four site params describe the page grid in numbers. They are read only by the Go templates, to compute the pixel width of an image before it is resized. They never produce a single line of CSS.
/config/_default/params.yaml
column:
mobile: 1
desktop: 12
container:
desktop: 1440
mobile: 375
gap:
desktop: 30
mobile: 30
gutter:
desktop: 60
mobile: 30
| Param | What it drives |
|---|---|
column.desktop | The divisor of the grid (12), how wide one column is |
column.mobile | Same, on mobile (1 → one column fills the container) |
container.desktop / .mobile | The reference width the gutters are subtracted from |
gap.desktop | The space between two columns, part of a multi-column span |
gutter.desktop / .mobile | The inside margin of the container, subtracted from the usable width |
A block asking for a 4-column image gets GetColumnWidth → (container − 2 gutters − gaps) / 12 × 4 + gaps, rounded up, and that number is what Hugo (or your image CDN) resizes to.
The CSS width of the container comes from tokens (--container-max-width-desktop, --gutter), not from these params. Change one side without the other and Hugo generates images calibrated for a width the page no longer has: too small, so upscaled and soft.
Each styling module ships the values matching its own grid: 1440 / 30 / 60 for the design system, 1296 / 24 / 30 for Bootstrap.
Dark mode is off by default. Turning it on hands the choice to the operating system:
/config/_default/params.yaml
css:
darkmode: true # false (default) forces light
css.darkmode | <meta name="color-scheme"> | Root attribute |
|---|---|---|
true | light dark | none, the OS decides |
false | only light | data-color-scheme="light", dark tokens suppressed |
The dark values ship with @uncinq/design-tokens and reassign the semantic color tokens only: --color-background, --color-text, --color-border… Components follow without a single dark-specific rule.
Your own dark values go under the same guard, so a site switched back to light keeps working:
/* assets/css/tokens/theme.css */
@layer tokens {
@media (prefers-color-scheme: dark) {
:root:not([data-color-scheme="light"]) {
--color-brand: oklch(0.7 0.16 22);
--header-color-background: var(--color-background);
}
}
}
Write dark overrides against semantic tokens, never against a component’s own colors: a component that reads --color-background flips on its own, while one that hardcodes a value has to be handled twice.
The module ships four empty files, imported last by main.css in this order — tokens first, rules after, and site after theme in each pair:
tokens/theme.css → tokens/site.css → theme.css → site.css
In both pairs the split is the same, and it is about lifespan, not about size: what a second site would want too goes in the theme file, typically shipped as a theme module of your own; what only this project needs goes in the site file. A project with no shared look uses the site files only, and never has to touch the theme ones.
The four files are empty in the module — create the one you need at the same path in your project, and Hugo’s asset priority (project > theme > modules) replaces it.
Two entry points, both imported after tokens/design-system.css, so they cascade over every default:
| File | Use for |
|---|---|
assets/css/tokens/theme.css | the tokens that define a reusable look — brand colors, typography, radius scale |
assets/css/tokens/site.css | the tokens this site alone changes |
/* assets/css/tokens/theme.css — the look, reusable across sites */
@layer tokens {
:root {
--color-brand: #e63946;
--btn-border-radius: var(--radius-pill);
}
}
/* assets/css/tokens/site.css — this site only, wins over the theme above */
@layer tokens {
:root {
--header-height: 72px;
}
}
Same split for rules that no token covers. Write to the same layer as the rule you are overriding:
| File | Use for |
|---|---|
assets/css/theme.css | rules that belong to the reusable look |
assets/css/site.css | rules this site alone needs |
/* assets/css/site.css */
@layer components {
.card .media img { filter: grayscale(1); }
}
Two things to check first: a token often covers the case (–card-color-background, –hero-min-height…), and a modifier class often exists (.hero-center, .block-dark, .btn-ghost…) — reach for the markup or the token before writing a rule. The block and hero classes are listed in UI.
Color variants set CSS custom properties scoped to the modifier class, consumed by the base component:
.alert { background-color: var(--alert-color-background); color: var(--alert-color-text); }
.alert-danger { --alert-color-background: var(--color-danger-muted); --alert-color-text: var(--color-danger-strong); }
.alert-success { --alert-color-background: var(--color-success-muted); --alert-color-text: var(--color-success-strong); }
Available variants (alert, badge, btn): brand, primary, secondary, neutral, success, danger, warning, info, light, dark.
Buttons add ghost, link and control variants, plus sizes btn-xs to btn-xl.