Density, Spacing & Font Size
How BeigeUI's spacing tokens work, and how one setting makes the whole interface tighter, airier or larger without touching a component.
How It Works
Density is a remap of shared CSS custom properties. Component styles never contain raw rem or px spacing. They read from a t-shirt scale of --beige-spacing-* tokens and a set of semantic --beige-padding-* tokens. BeThemeService rewrites the values of those tokens on the root element, so every component follows the change with no re-render and no per-component input.
tokens.scssdeclares the default (comfortable) scale.- The
densitysetting selects a map of overrides:compact,comfortableorspacious. - The service writes that map as inline custom properties on
<html>, which wins over the stylesheet defaults. - Components resolve
var(--beige-spacing-*)and pick up the new values immediately.
The Spacing Scale
The default scale runs from 3xs (2px) to 4xl (48px). Components consume it directly:
:root {
--beige-spacing-3xs: 0.125rem; /* 2px */
--beige-spacing-2xs: 0.25rem; /* 4px */
--beige-spacing-xs: 0.375rem; /* 6px */
--beige-spacing-sm: 0.5rem; /* 8px */
--beige-spacing-md: 0.75rem; /* 12px */
--beige-spacing-base: 1rem; /* 16px */
--beige-spacing-lg: 1.25rem; /* 20px */
--beige-spacing-xl: 1.5rem; /* 24px */
--beige-spacing-2xl: 2rem; /* 32px */
--beige-spacing-3xl: 2.5rem; /* 40px */
--beige-spacing-4xl: 3rem; /* 48px */
}.be-example {
/* Components only ever read tokens, never raw rem / px values. */
padding: var(--beige-spacing-sm) var(--beige-spacing-md);
gap: var(--beige-spacing-xs);
}Density Values
Each density option shifts the middle of the scale by about one step. Values below are in pixels.
| Token | Compact | Comfortable (default) | Spacious |
|---|---|---|---|
--beige-spacing-xs | 4 | 6 | 8 |
--beige-spacing-sm | 6 | 8 | 12 |
--beige-spacing-md | 8 | 12 | 16 |
--beige-spacing-base | 12 | 16 | 20 |
--beige-spacing-lg | 16 | 20 | 24 |
--beige-spacing-xl | 20 | 24 | 32 |
--beige-padding-sm-x / y | 8 / 4 | 12 / 6 | 16 / 8 |
--beige-padding-md-x / y | 12 / 6 | 16 / 8 | 20 / 10 |
--beige-padding-lg-x / y | 16 / 8 | 24 / 12 | 28 / 16 |
3xs, 2xs, 2xl, 3xl and 4xl are not remapped. They stay fixed, which suits hairline gaps and large layout gaps that should not breathe with density. The card tokens (--beige-padding-card-header, -content, -footer) are built from --beige-spacing-* and follow density automatically.
Density vs. Component Size
The two are independent. A component's size input (sm, md, lg) chooses which padding token pair it uses, and density then rescales that pair. A lg button in compact mode is still larger than an md button in the same mode. Use size for per-control emphasis and density for the overall feel of the app.
Building Density-Aware Custom Components
A component in your own application follows density the same way library components do: by reading the shared tokens instead of hardcoding lengths. There is nothing to register and no service to inject for styling. As an example, take an app-stat-tile that should tighten in compact mode and relax in spacious mode.
1. Style with tokens
Use --beige-spacing-* for gaps and paddings, and the --beige-padding-{sm|md|lg}-{x|y} pairs for controls that come in sizes. Expose an optional component-level hook whose fallback is a token, so consumers can override it for one instance.
:host {
display: block;
/* Public hook with a token fallback, same pattern as --be-page-header-padding-x. */
padding:
var(--app-stat-tile-padding-y, var(--beige-spacing-md))
var(--app-stat-tile-padding-x, var(--beige-spacing-base));
border: 1px solid var(--beige-border-subtle);
border-radius: var(--beige-radius-md);
background: var(--beige-surface-card);
}
.stat-tile__body {
display: flex;
flex-direction: column;
gap: var(--beige-spacing-2xs); /* fixed: hairline gap, stays put */
}
.stat-tile__actions {
display: flex;
gap: var(--beige-spacing-sm); /* scales with density */
margin-top: var(--beige-spacing-md);
}
/* Size variants pick a token pair. Density rescales the pair. */
:host(.stat-tile--sm) {
padding: var(--beige-padding-sm-y) var(--beige-padding-sm-x);
}
:host(.stat-tile--lg) {
padding: var(--beige-padding-lg-y) var(--beige-padding-lg-x);
}2. Map sizes to token pairs
Keep size and density separate, as the framework does. The size input only selects which token pair applies. It should never contain pixel values.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-stat-tile',
standalone: true,
templateUrl: './stat-tile.component.html',
styleUrl: './stat-tile.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.stat-tile--sm]': "size() === 'sm'",
'[class.stat-tile--lg]': "size() === 'lg'"
}
})
export class StatTileComponent {
/** Padding scale. Density rescales it. @default 'md' */
readonly size = input<'sm' | 'md' | 'lg'>('md');
}3. Choose the right token
| Need | Use | Reacts to density |
|---|---|---|
| Gaps and padding between regions | --beige-spacing-xs to --beige-spacing-xl | Yes |
| Control padding in sm / md / lg sizes | --beige-padding-*-x / -y | Yes |
| Card-like sections | --beige-padding-card-header | content | footer | Yes (root density) |
| Hairline gaps, icon-to-text nudges | --beige-spacing-3xs, --beige-spacing-2xs | No |
| Large page or section gaps | --beige-spacing-2xl to --beige-spacing-4xl | No |
4. Declare derived tokens on the host
If your component defines its own token from a spacing token, declare it on :host, not on :root. This keeps it correct inside a scoped density region (see the previous section).
/* A derived token declared on :host resolves against the host's own
(already density-adjusted) values, so it follows density correctly. */
:host {
--stat-tile-gutter: var(--beige-spacing-md);
padding: var(--stat-tile-gutter);
}
/* Avoid: declaring the derived token on :root in a global stylesheet
resolves it once, so a scoped density override will not reach it. */
:root {
--stat-tile-gutter: var(--beige-spacing-md);
}5. Read the density in code only when you must
Prefer CSS. If TypeScript needs a number, such as a virtual-scroll item size, read the BeThemeService.density() signal in a computed so it updates when the density changes. On the server the signal holds the configured default, so avoid measuring the DOM before the first render.
import { computed, inject } from '@angular/core';
import { BeThemeService } from '@saarcmaststech/beige-ui';
// Only when JavaScript needs a number CSS cannot supply, for example the
// fixed row height of a virtual scroller.
private readonly theme = inject(BeThemeService);
protected readonly rowHeight = computed(() => {
switch (this.theme.density()) {
case 'compact': return 32;
case 'spacious': return 48;
default: return 40;
}
});Checklist
- No raw
px/remfor padding, margin or gap, except intentional fixed hairlines. - Sizes map to
--beige-padding-*pairs, not to numbers. - Optional per-component hooks fall back to a token:
var(--app-x, var(--beige-spacing-md)). - Derived tokens are declared on
:host. - Fixed heights (for example a 40px control) are chosen deliberately, because density does not change them.
- Verify by switching to compact and spacious with
theme.setDensity().
Setting the Density
Set the app-wide default with provideBeThemeConfig:
import { provideBeThemeConfig } from '@saarcmaststech/beige-ui';
export const appConfig: ApplicationConfig = {
providers: [
// Every user starts with the compact scale.
provideBeThemeConfig({ density: 'compact' })
]
}; Change it at runtime through BeThemeService. The choice is persisted with the rest of the theme (see Configuration for storageKey), and theme.density() is a signal you can bind to.
import { Component, inject } from '@angular/core';
import { BeThemeService } from '@saarcmaststech/beige-ui';
@Component({ /* ... */ })
export class SettingsComponent {
protected readonly theme = inject(BeThemeService);
protected setDense(dense: boolean): void {
this.theme.setDensity(dense ? 'compact' : 'comfortable');
}
}Font Size
Font size works like density: one setting remaps shared tokens. BeThemeService sets a single multiplier, --beige-font-scale, and every --beige-font-size-* step is defined as its rem value times that multiplier. The whole type scale grows or shrinks together, so the relationships between sizes are preserved.
:root {
--beige-font-scale: 1; /* set by BeThemeService.setFontSize() */
--beige-font-size-xs: calc(0.75rem * var(--beige-font-scale));
--beige-font-size-sm: calc(0.8125rem * var(--beige-font-scale));
--beige-font-size-md: calc(0.875rem * var(--beige-font-scale));
--beige-font-size-base: calc(1rem * var(--beige-font-scale));
/* ...lg to 4xl follow the same pattern */
}| Setting | Multiplier | sm | md | base | xl |
|---|---|---|---|---|---|
compact | 0.9375 | 12.2px | 13.1px | 15px | 18.8px |
default | 1 | 13px | 14px | 16px | 20px |
large | 1.0625 | 13.8px | 14.9px | 17px | 21.3px |
Set the default with provideBeThemeConfig or change it at runtime. It is persisted with the rest of the theme.
// App default
provideBeThemeConfig({ fontSize: 'large' });
// At runtime
theme.setFontSize('compact'); // 'compact' | 'default' | 'large'In your own components
Use --beige-font-size-* instead of raw sizes, and your text follows the setting with no other work. For a size that is not on the scale, multiply by --beige-font-scale.
.stat-tile__value {
font-size: var(--beige-font-size-2xl); /* follows the type scale */
line-height: var(--beige-line-height-tight);
}
.stat-tile__label {
font-size: var(--beige-font-size-sm);
}
/* One-off size that should still scale with the setting */
.stat-tile__badge {
font-size: calc(0.6875rem * var(--beige-font-scale, 1));
}Things to know
- The scale is in
rem, so the browser's or yourhtmlfont size is the starting point and the multiplier sits on top of it. - Font size does not change spacing or fixed control heights. Pair it with density if you need both, and check controls with a fixed height in
large. - The tokens resolve on
:root, so overriding--beige-font-scaleon a container does not rescale its children. To scale a region, redeclare the--beige-font-size-*tokens there. - Text that hardcodes a
pxorremsize does not follow the setting.
Scoping Density to a Region
The service applies density at the root, but custom properties cascade, so you can override tokens on any container. There is one catch: tokens defined in terms of other tokens are resolved where they are declared. The card padding tokens are declared on :root, so overriding --beige-spacing-* on a container does not change them. Redeclare them alongside the spacing values.
/* Dense region inside a comfortable app. */
.dense-region {
--beige-spacing-sm: 0.375rem;
--beige-spacing-md: 0.5rem;
--beige-padding-md-x: 0.75rem;
--beige-padding-md-y: 0.375rem;
/* Derived tokens were resolved on :root, so redeclare them here. */
--beige-padding-card-header: var(--beige-spacing-base) var(--beige-spacing-lg);
--beige-padding-card-content: var(--beige-spacing-md) var(--beige-spacing-lg);
--beige-padding-card-footer: var(--beige-spacing-md) var(--beige-spacing-lg);
}Fine-Tuning a Single Step
The density maps are built in and are not configurable through BeThemeConfig. To adjust one token, override it in your own stylesheet. Note that the service writes inline styles on the root element, which win over a plain :root rule, so to change a value the service sets, use a scoped container as shown above or !important.
/* Fine-tune a single step after the service has applied its values. */
:root {
--beige-spacing-base: 0.875rem;
}