ThemingSpacingRuntime

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.

  1. tokens.scss declares the default (comfortable) scale.
  2. The density setting selects a map of overrides: compact, comfortable or spacious.
  3. The service writes that map as inline custom properties on <html>, which wins over the stylesheet defaults.
  4. 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:

tokens.scsscss
: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 */
}
component.scsscss
.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.

TokenCompactComfortable (default)Spacious
--beige-spacing-xs468
--beige-spacing-sm6812
--beige-spacing-md81216
--beige-spacing-base121620
--beige-spacing-lg162024
--beige-spacing-xl202432
--beige-padding-sm-x / y8 / 412 / 616 / 8
--beige-padding-md-x / y12 / 616 / 820 / 10
--beige-padding-lg-x / y16 / 824 / 1228 / 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.

stat-tile.component.scssscss
: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.

stat-tile.component.tstypescript
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

NeedUseReacts to density
Gaps and padding between regions--beige-spacing-xs to --beige-spacing-xlYes
Control padding in sm / md / lg sizes--beige-padding-*-x / -yYes
Card-like sections--beige-padding-card-header | content | footerYes (root density)
Hairline gaps, icon-to-text nudges--beige-spacing-3xs, --beige-spacing-2xsNo
Large page or section gaps--beige-spacing-2xl to --beige-spacing-4xlNo

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).

derived-tokens.scsscss
/* 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.

stat-tile.component.tstypescript
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 / rem for 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:

app.config.tstypescript
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.

settings.component.tstypescript
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.

tokens.scsscss
: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 */
}
SettingMultipliersmmdbasexl
compact0.937512.2px13.1px15px18.8px
default113px14px16px20px
large1.062513.8px14.9px17px21.3px

Set the default with provideBeThemeConfig or change it at runtime. It is persisted with the rest of the theme.

font-size.tstypescript
// 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.component.scssscss
.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 your html font 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-scale on a container does not rescale its children. To scale a region, redeclare the --beige-font-size-* tokens there.
  • Text that hardcodes a px or rem size 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.csscss
/* 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);
}
Default
Scoped compact padding

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.

styles.csscss
/* Fine-tune a single step after the service has applied its values. */
:root {
  --beige-spacing-base: 0.875rem;
}