CSS Custom Properties and Logical Properties

Introduction

There is a pattern I have seen in virtually every large frontend codebase I have worked in. Design tokens duplicated across multiple files. Theme switching implemented in JavaScript, managing class names and computed styles at runtime. Layout code that breaks the moment someone needs right-to-left language support. Responsive components that re-implement the same spacing logic in seventeen different breakpoint overrides. Component variants handled through prop-driven className concatenation instead of anything the CSS itself can express.

All of it — every one of those patterns — has a better solution that lives entirely in CSS. Two features that have been available in modern browsers for years, that are supported without a polyfill in every browser a production application realistically targets, and that most developers either don’t know about or don’t know deeply enough to reach for when the problem is in front of them.

CSS Custom Properties. CSS Logical Properties.

I have used both extensively across projects — building component libraries, migrating legacy codebases, implementing bidirectional layouts for platforms that serve Arabic and Hebrew users alongside English ones. This article is what I know about both: how they work at the model level, where they genuinely solve hard problems, the patterns that took me time to arrive at, and the mistakes I see consistently in codebases that are using them partially but not fully.

The goal is not to make you aware of these features. The goal is to make you reach for them automatically when the problem fits, because right now, most developers don’t — and their CSS is more complicated and less maintainable than it needs to be.

CSS Custom Properties: Not Just Variables

The internet call them CSS variables. That name undersells them significantly and leads to underuse. They are variables in the sense that they store values. But the behaviour that makes them genuinely powerful is not the storage — it is the cascade, the inheritance, and the ability to change them at runtime without touching JavaScript.

How they actually work

Custom properties follow the CSS cascade. They are inherited by default. They can be defined at any scope and redefined at a narrower scope. The computed value at any element is the value of the property on the nearest ancestor that defines it.

/* Defined at root — available everywhere */
:root {
  --color-primary: #0066cc;
  --color-surface: #ffffff;
  --spacing-base: 1rem;
}

/* Redefined at component scope — only affects this component and its children */
.card--featured {
  --color-primary: #ff6b00;
  --color-surface: #fff8f0;
}

Every element inside .card--featured sees the orange primary colour. Everything outside it sees the blue. No JavaScript. No class toggling. No prop drilling. The CSS cascade handled it.

This is the model most developers are not using — and it is the one that makes everything else in this article work.

The difference from preprocessor variables

Sass variables, Less variables — these are compile-time constructs. They are substituted at build time and disappear. The output CSS contains the computed values, not the variable references.

// Sass — this is what the browser sees after compilation
$color-primary: #0066cc;

.button {
  background: #0066cc; // The variable is gone — this is a static value
}

CSS custom properties exist at runtime. The browser resolves them. They can be changed by JavaScript without a rebuild. They respond to media queries, pseudo-classes, and CSS context. They can reference other custom properties. They are a living part of the stylesheet, not a preprocessing step.

/* This exists in the browser — it can be changed at runtime */
.button {
  background: var(--color-primary); /* The reference is live */
}
// Change the variable — every element using it updates immediately
document.documentElement.style.setProperty('--color-primary', '#ff6b00');

That one JavaScript line updates every element in the entire application that uses --color-primary. No class swapping. No component re-renders. No runtime style computation. The browser handles it.

Fallback values

var() accepts a fallback as the second argument:

.button {
  /* Use --button-bg if defined, fall back to --color-primary */
  background: var(--button-bg, var(--color-primary));

  /* Fallbacks can be chained */
  color: var(--button-text, var(--color-on-primary, #ffffff));

  /* Fallback to a static value */
  border-radius: var(--button-radius, 4px);
}

This fallback chain is the foundation of component-level theming. The component defines what it looks like by default. Consumers override the specific variables they need. Everything else falls through to the default.

Invalid at computed value time

One behaviour of custom properties that catches developers off guard: if a custom property contains an invalid value for the property it’s used in, the browser does not use the fallback. It uses the inherited value or the initial value for that property.

:root {
  --size: 'large'; /* A string — invalid for a numeric property */
}

.element {
  width: var(
    --size
  ); /* Invalid — browser uses initial value for width, not the fallback */
  width: var(
    --size,
    100px
  ); /* Still doesn't use 100px — the fallback is for undefined, not invalid */
}

The fallback only applies when the variable is not defined at all. If it is defined but invalid for the context, the fallback is not used. Design your token values to be valid CSS values for every context they will be used in.

Theming: The Right Architecture

Most theming implementations I’ve seen in codebases fall into one of two categories: a JavaScript theme object passed through context, or a set of Sass variables compiled separately for each theme. Both have real costs. The JavaScript approach re-renders the component tree on theme change and ties the visual system to the JavaScript framework. The Sass approach requires separate compiled stylesheets and either a stylesheet swap or duplicate CSS.

CSS custom properties make both of those unnecessary.

A token-based theme system

/* tokens.css — the single source of truth */
:root {
  /* Primitive tokens — raw values, not semantic */
  --blue-100: #dbeafe;
  --blue-500: #3b82f6;
  --blue-900: #1e3a5f;

  --grey-50: #f9fafb;
  --grey-100: #f3f4f6;
  --grey-200: #e5e7eb;
  --grey-700: #374151;
  --grey-900: #111827;

  --orange-500: #f97316;
  --orange-600: #ea6c00;

  /* Semantic tokens — reference primitives, describe intent */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-900);
  --color-surface: var(--grey-50);
  --color-surface-raised: #ffffff;
  --color-text-primary: var(--grey-900);
  --color-text-secondary: var(--grey-700);
  --color-border: var(--grey-200);
  --color-focus-ring: var(--blue-100);

  /* Spacing scale */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;

  /* Typography */
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
  --font-size-xl: 1.25rem;
  --font-size-2xl: 1.5rem;
  --font-size-3xl: 1.875rem;

  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  --line-height-tight: 1.25;
  --line-height-base: 1.5;
  --line-height-relaxed: 1.75;

  /* Radii */
  --radius-sm: 4px;
  --radius-base: 8px;
  --radius-lg: 12px;
  --radius-full: 9999px;

  /* Shadows */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
  --shadow-lg:
    0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);

  /* Transitions */
  --duration-fast: 100ms;
  --duration-base: 200ms;
  --duration-slow: 300ms;
  --easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
}

Dark mode — entirely in CSS

/* Dark theme — redefine semantic tokens only */
[data-theme='dark'] {
  --color-primary: var(--blue-100);
  --color-primary-hover: #ffffff;
  --color-surface: #0f172a;
  --color-surface-raised: #1e293b;
  --color-text-primary: #f1f5f9;
  --color-text-secondary: #94a3b8;
  --color-border: #334155;
  --color-focus-ring: var(--blue-900);
}

/* System preference — no JavaScript required */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-primary: var(--blue-100);
    --color-primary-hover: #ffffff;
    --color-surface: #0f172a;
    --color-surface-raised: #1e293b;
    --color-text-primary: #f1f5f9;
    --color-text-secondary: #94a3b8;
    --color-border: #334155;
    --color-focus-ring: var(--blue-900);
  }
}

Toggle dark mode:

// That's all — no framework, no re-render, no computed styles
document.documentElement.setAttribute('data-theme', 'dark');

The primitive tokens never change. The semantic tokens change meaning between themes. The components use semantic tokens and know nothing about themes. This separation is what makes the system scalable — adding a new theme is defining semantic tokens, not touching any component CSS.

Component-level theming — the scoped override pattern

This is the pattern most developers are not using, and it is where CSS custom properties become genuinely powerful for design systems.

A component defines its own custom properties — a component API in CSS. Consumers override those properties at any scope:

/* Button component — defines its own layer of tokens */
.button {
  /* Defaults — reference global tokens */
  --button-bg: var(--color-primary);
  --button-bg-hover: var(--color-primary-hover);
  --button-text: var(--color-surface-raised);
  --button-border-color: transparent;
  --button-radius: var(--radius-base);
  --button-padding-x: var(--space-4);
  --button-padding-y: var(--space-2);
  --button-font-size: var(--font-size-base);
  --button-font-weight: var(--font-weight-medium);
  --button-shadow: none;
  --button-transition: var(--duration-base) var(--easing-standard);

  /* Use its own tokens — not global tokens directly */
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  background: var(--button-bg);
  color: var(--button-text);
  border: 1px solid var(--button-border-color);
  border-radius: var(--button-radius);
  padding: var(--button-padding-y) var(--button-padding-x);
  font-size: var(--button-font-size);
  font-weight: var(--button-font-weight);
  box-shadow: var(--button-shadow);
  transition:
    background var(--button-transition),
    border-color var(--button-transition),
    color var(--button-transition);
  cursor: pointer;
}

.button:hover {
  background: var(--button-bg-hover);
}

/* Variants — only override what changes */
.button--secondary {
  --button-bg: transparent;
  --button-bg-hover: var(--color-surface);
  --button-text: var(--color-primary);
  --button-border-color: var(--color-border);
}

.button--ghost {
  --button-bg: transparent;
  --button-bg-hover: var(--color-surface);
  --button-text: var(--color-text-primary);
  --button-border-color: transparent;
}

.button--danger {
  --button-bg: #dc2626;
  --button-bg-hover: #b91c1c;
}

.button--sm {
  --button-padding-x: var(--space-3);
  --button-padding-y: var(--space-1);
  --button-font-size: var(--font-size-sm);
  --button-radius: var(--radius-sm);
}

.button--lg {
  --button-padding-x: var(--space-6);
  --button-padding-y: var(--space-3);
  --button-font-size: var(--font-size-lg);
  --button-radius: var(--radius-lg);
}

A consumer can override any aspect at any scope without touching the component CSS:

/* In a specific section — pill-shaped buttons with different colours */
.hero-section {
  --button-radius: var(--radius-full);
  --button-bg: var(--orange-500);
  --button-bg-hover: var(--orange-600);
}

/* A specific button instance — inline override */
.submit-button {
  --button-bg: #16a34a;
  --button-bg-hover: #15803d;
  --button-shadow: var(--shadow-base);
}

No modifier classes invented by the consumer. No !important. No specificity battles. The cascade handles it. The component’s internal structure stays intact.

Responsive Design Without Breakpoint Sprawl

Custom properties enable responsive design patterns that eliminate the breakpoint repetition most codebases are built around.

Fluid spacing with clamp

:root {
  /* Fluid values — scale linearly between viewport widths */
  /* clamp(minimum, preferred, maximum) */
  --space-fluid-sm: clamp(0.5rem, 1vw, 1rem);
  --space-fluid-base: clamp(1rem, 2vw, 2rem);
  --space-fluid-lg: clamp(1.5rem, 3vw, 3rem);
  --space-fluid-xl: clamp(2rem, 5vw, 5rem);

  --font-size-fluid-base: clamp(1rem, 0.875rem + 0.5vw, 1.25rem);
  --font-size-fluid-lg: clamp(1.25rem, 1rem + 1vw, 2rem);
  --font-size-fluid-xl: clamp(1.5rem, 1rem + 2vw, 3rem);
  --font-size-fluid-2xl: clamp(2rem, 1.5rem + 3vw, 4.5rem);
}

/* Zero breakpoints. The heading scales fluidly from mobile to desktop. */
.page-title {
  font-size: var(--font-size-fluid-2xl);
  margin-block-end: var(--space-fluid-lg);
}

No media query. No font-size: 2rem at mobile, font-size: 3rem at tablet, font-size: 4.5rem at desktop. One declaration, fluid across every viewport width.

Responsive component layout with custom properties

.card-grid {
  --grid-min-column-width: 280px;
  --grid-gap: var(--space-fluid-base);

  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(var(--grid-min-column-width), 1fr)
  );
  gap: var(--grid-gap);
}

/* Consumer adjusts without touching the component */
.dashboard .card-grid {
  --grid-min-column-width: 360px;
}

.sidebar .card-grid {
  --grid-min-column-width: 100%;
}

The grid responds to available space, not viewport width. This is container-aware responsive design before container queries existed. The custom properties make the thresholds controllable without rewriting the layout logic.

Contextual spacing — the space-aware component

/* A component that adapts its internal spacing based on context */
.section {
  --section-padding: var(--space-fluid-xl);
  --section-max-width: 75rem;
  --section-gap: var(--space-fluid-lg);

  padding-block: var(--section-padding);
  max-width: var(--section-max-width);
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  gap: var(--section-gap);
}

/* Compact context — dialog, sidebar, card */
.dialog .section,
.sidebar .section {
  --section-padding: var(--space-fluid-base);
  --section-gap: var(--space-fluid-sm);
}

/* Narrow context */
.narrow-layout .section {
  --section-max-width: 45rem;
}

One component definition. Contextually adapted by its container. No variant classes, no prop drilling, no media queries duplicated for every layout context.

CSS Logical Properties: The Layout Revolution Nobody Adopted

Logical properties are the feature I find most consistently absent from codebases that should be using them. They are available in every modern browser. The support has been solid since 2020. And yet most CSS I encounter in production still uses physical directional properties — margin-left, padding-top, border-right, left, right — in ways that silently break when the content direction changes.

Physical vs logical: the core concept

Physical properties reference directions in the physical viewport: top, right, bottom, left. Logical properties reference directions relative to the content flow: block (perpendicular to text direction), inline (parallel to text direction), start, end.

In a left-to-right horizontal writing mode — English, French, German — these map as follows:

Logical PropertyPhysical Equivalent (LTR)Physical Equivalent (RTL)
margin-inline-startmargin-leftmargin-right
margin-inline-endmargin-rightmargin-left
margin-block-startmargin-topmargin-top
margin-block-endmargin-bottommargin-bottom
padding-inlinepadding-left + padding-rightsame
padding-blockpadding-top + padding-bottomsame
inset-inline-startleftright
inset-block-starttoptop
border-inline-endborder-rightborder-left

When the writing direction is RTL (Arabic, Hebrew, Persian) or when using a vertical writing mode (Japanese, traditional Chinese), the logical properties adapt automatically. The physical properties do not.

The RTL problem physical properties create

/* Physical properties — will break in RTL */
.nav-item {
  padding-left: 1rem; /* Should be padding-inline-start */
  margin-right: 0.5rem; /* Should be margin-inline-end */
  border-left: 3px solid var(--color-primary); /* Should be border-inline-start */
  text-align: left; /* Should be text-align: start */
}

.icon {
  margin-right: 0.5rem; /* Icon is always to the left — wrong in RTL */
}

.dropdown {
  right: 0; /* Should be inset-inline-end: 0 */
}

In RTL mode, all of these are wrong. The padding is on the wrong side. The border accent is on the wrong edge. The icon margin is reversed. The dropdown opens in the wrong direction. Fixing them in RTL requires either duplicating every declaration inside a [dir="rtl"] selector — doubling your layout CSS — or rewriting with logical properties.

Logical properties fix this by default

/* Logical properties — correct in LTR, RTL, and vertical writing modes */
.nav-item {
  padding-inline-start: 1rem;
  margin-inline-end: 0.5rem;
  border-inline-start: 3px solid var(--color-primary);
  text-align: start;
}

.icon {
  margin-inline-end: 0.5rem;
}

.dropdown {
  inset-inline-end: 0;
}

No [dir="rtl"] overrides. No duplicated CSS. No JavaScript to detect direction and swap classes. The browser handles it. Declare the direction on the html element and every logical property adapts.

<html dir="rtl" lang="ar"></html>

That’s the entire implementation for direction switching.

The complete logical property reference

Sizing:

.element {
  /* Replaces: width */
  inline-size: 100%;
  min-inline-size: 280px;
  max-inline-size: 75rem;

  /* Replaces: height */
  block-size: auto;
  min-block-size: 48px;
  max-block-size: 100vh;
}

Margin:

.element {
  /* Replaces: margin-top, margin-bottom */
  margin-block: 1rem;
  margin-block-start: 0;
  margin-block-end: 2rem;

  /* Replaces: margin-left, margin-right */
  margin-inline: auto;
  margin-inline-start: 1rem;
  margin-inline-end: 0;
}

Padding:

.element {
  /* Replaces: padding-top, padding-bottom */
  padding-block: 1.5rem;
  padding-block-start: 1rem;
  padding-block-end: 2rem;

  /* Replaces: padding-left, padding-right */
  padding-inline: 1rem;
  padding-inline-start: 1.5rem;
  padding-inline-end: 1rem;
}

Borders:

.element {
  border-block: 1px solid var(--color-border);
  border-block-start: 2px solid var(--color-primary);
  border-block-end: none;

  border-inline: none;
  border-inline-start: 3px solid var(--color-primary);
  border-inline-end: 1px solid var(--color-border);

  border-start-start-radius: var(--radius-base);
  border-start-end-radius: var(--radius-base);
  border-end-start-radius: 0;
  border-end-end-radius: 0;
}

border-start-start-radius replaces border-top-left-radius in LTR. In RTL, it becomes the top-right corner. The name encodes the logic: start of the block axis, start of the inline axis.

Positioning:

.element {
  position: absolute;

  /* Replaces: top */
  inset-block-start: 0;
  /* Replaces: bottom */
  inset-block-end: 0;
  /* Replaces: left */
  inset-inline-start: 0;
  /* Replaces: right */
  inset-inline-end: 0;

  /* Shorthand — replaces: top: 0; right: 0; bottom: 0; left: 0 */
  inset: 0;

  /* Block axis only — replaces: top + bottom */
  inset-block: 0;

  /* Inline axis only — replaces: left + right */
  inset-inline: 0;
}

Text and overflow:

.element {
  /* Replaces: text-align: left */
  text-align: start;
  /* Replaces: text-align: right */
  text-align: end;

  /* Float — respects writing direction */
  float: inline-start; /* Always floats to the start of inline direction */
  float: inline-end;
}

When to use physical and when to use logical

This is a nuance most articles miss. Logical properties are correct for layout — spacing, positioning, borders that relate to content flow. Physical properties are correct for things that are genuinely directional in the physical sense.

/* Use physical — these are viewport-relative, not content-flow-relative */
.fixed-header {
  position: fixed;
  top: 0; /* Always the physical top of the viewport */
  left: 0; /* Always the physical left */
  right: 0; /* Always the physical right */
}

/* Use logical — these are content-flow-relative */
.card {
  padding-inline: var(--space-4); /* Space on reading-direction sides */
  padding-block: var(--space-3); /* Space on block-direction sides */
  border-inline-start: 3px solid var(--color-primary); /* Leading edge accent */
}

If you’re ever unsure: ask whether the property should flip in RTL. If yes — logical. If it should stay the same regardless of direction — physical.

Combining Custom and Logical Properties: The Real Power

Custom properties and logical properties are individually powerful. Together they become the foundation for a CSS architecture that handles complexity that currently lives in JavaScript.

A fully adaptive component

/* A card component that adapts to theme, context, size, and direction */
.card {
  /* Component API — overridable by consumers */
  --card-bg: var(--color-surface-raised);
  --card-border-color: var(--color-border);
  --card-radius: var(--radius-base);
  --card-shadow: var(--shadow-base);
  --card-padding-block: var(--space-4);
  --card-padding-inline: var(--space-4);
  --card-gap: var(--space-3);

  /* Implementation — uses component tokens and logical properties */
  background: var(--card-bg);
  border: 1px solid var(--card-border-color);
  border-radius: var(--card-radius);
  box-shadow: var(--card-shadow);
  padding-block: var(--card-padding-block);
  padding-inline: var(--card-padding-inline);
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

/* Accent variant — leading edge border */
.card--accented {
  --card-accent-color: var(--color-primary);
  --card-accent-width: 4px;

  border-inline-start: var(--card-accent-width) solid var(--card-accent-color);
  /* In RTL, this border appears on the right — the correct leading edge */
}

/* Horizontal layout variant */
.card--horizontal {
  flex-direction: row;
  --card-padding-block: var(--space-3);
}

/* Compact context */
.sidebar .card,
.dialog .card {
  --card-padding-block: var(--space-3);
  --card-padding-inline: var(--space-3);
  --card-gap: var(--space-2);
  --card-shadow: none;
}

This single component definition:

  • Adapts to dark and light themes via semantic tokens
  • Adapts to RTL via logical properties
  • Adapts to context via scoped variable overrides
  • Provides a clean API for consumer customisation
  • Uses zero media queries for its layout

Animated custom properties

Custom properties can be animated in modern browsers:

@property --gradient-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.animated-border {
  --gradient-angle: 0deg;

  border: 2px solid transparent;
  background:
    linear-gradient(var(--card-bg), var(--card-bg)) padding-box,
    linear-gradient(
        var(--gradient-angle),
        var(--color-primary),
        var(--color-secondary)
      )
      border-box;

  animation: rotate-gradient 3s linear infinite;
}

@keyframes rotate-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

Without @property, you cannot animate custom properties that contain values like angles, because the browser doesn’t know the type. @property registers the variable with a type, enabling animation and transition.

/* Registering a token for animation */
@property --progress {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.progress-bar {
  --progress: 0;

  background: linear-gradient(
    to inline-end,
    var(--color-primary) calc(var(--progress) * 100%),
    var(--color-surface) calc(var(--progress) * 100%)
  );

  transition: --progress 600ms var(--easing-standard);
}
progressBar.style.setProperty('--progress', '0.75');
// Animates the fill smoothly — no JavaScript animation loop

Custom properties as computed state

Custom properties can perform lightweight calculations:

:root {
  --columns: 3;
  --gap: 1.5rem;
  --column-width: calc(
    (100% - (var(--columns) - 1) * var(--gap)) / var(--columns)
  );
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--columns), var(--column-width));
  gap: var(--gap);
}

/* Change one variable — layout recalculates */
@media (max-width: 60rem) {
  :root {
    --columns: 2;
  }
}

@media (max-width: 36rem) {
  :root {
    --columns: 1;
  }
}

This is not a complicated example. But notice what it’s doing: the media query changes one number, and the entire layout recalculates. No duplicated grid-template-columns declarations. The relationship between columns, gap, and column width is encoded once.

The Patterns Most Codebases Are Missing

The —is-dark hack — boolean custom properties

CSS doesn’t have conditional logic, but you can approximate it:

:root {
  --is-dark: 0; /* Light mode — falsy value */
}

[data-theme='dark'] {
  --is-dark: 1; /* Dark mode — truthy value */
}

.element {
  /* Use calc to switch between values based on --is-dark */
  /* When --is-dark is 0: rgb(0 0 0 / 0) — transparent */
  /* When --is-dark is 1: rgb(0 0 0 / 0.5) — semi-transparent */
  background: rgb(0 0 0 / calc(var(--is-dark) * 0.5));

  /* Space toggle pattern — switch between 0 and a value */
  padding-block-start: calc(
    var(--is-dark) * 1rem
  ); /* 0 in light, 1rem in dark */
}

This is a technique from Lea Verou and others that uses CSS numeric custom properties as toggles. It is a niche technique and not always readable enough for a shared codebase — document it when you use it. But for performance-critical visual effects that would otherwise require class toggling and re-renders, it is genuinely powerful.

Container queries with custom properties

Custom properties and container queries combine cleanly:

.card-container {
  container-type: inline-size;
  container-name: card;
}

.card {
  --card-layout: column;
  --card-image-width: 100%;

  display: flex;
  flex-direction: var(--card-layout);
}

.card__image {
  inline-size: var(--card-image-width);
}

/* When the container is wide enough, switch to horizontal layout */
@container card (inline-size >= 480px) {
  .card {
    --card-layout: row;
    --card-image-width: 280px;
  }
}

The layout logic lives in the variable definitions. The structure is defined once. The media query — container query, here — only changes the variables.

Logical properties with writing-mode for vertical text

/* Vertical text navigation — common in East Asian design */
.vertical-nav {
  writing-mode: vertical-rl;

  /* These now refer to the vertical axis as "inline" */
  padding-inline: var(--space-4); /* top and bottom in vertical mode */
  gap: var(--space-2);
}

.vertical-nav__item {
  /* text-align: start works correctly in vertical mode */
  text-align: start;

  /* border-block-end is the right border in vertical-rl */
  border-block-end: 1px solid var(--color-border);
}

Logical properties with writing-mode let you build vertical layouts that work correctly without re-engineering the spacing and border logic for the new axis.

Migration: Moving From Physical to Logical

If you are working in a codebase that uses physical properties throughout, migrating to logical is straightforward in principle and achievable incrementally.

The migration does not need to happen all at once. Start new components with logical properties. Migrate existing components feature by feature. Use this mapping as the working reference:

/* Before → After */
margin-top:     → margin-block-start:
margin-bottom:  → margin-block-end:
margin-left:    → margin-inline-start:
margin-right:   → margin-inline-end:

padding-top:    → padding-block-start:
padding-bottom: → padding-block-end:
padding-left:   → padding-inline-start:
padding-right:  → padding-inline-end:

top:            → inset-block-start:
bottom:         → inset-block-end:
left:           → inset-inline-start:
right:          → inset-inline-end:

width:          → inline-size:
height:         → block-size:
min-width:      → min-inline-size:
max-width:      → max-inline-size:
min-height:     → min-block-size:
max-height:     → max-block-size:

text-align: left:  → text-align: start:
text-align: right: → text-align: end:

border-top-left-radius:     → border-start-start-radius:
border-top-right-radius:    → border-start-end-radius:
border-bottom-left-radius:  → border-end-start-radius:
border-bottom-right-radius: → border-end-end-radius:

The shorthand equivalents:

/* Physical shorthand → Logical equivalent */
margin: 1rem 2rem;         → margin-block: 1rem; margin-inline: 2rem;
padding: 1rem 2rem;        → padding-block: 1rem; padding-inline: 2rem;

/* Or the logical shorthands directly */
margin: 1rem 2rem;         → margin: 1rem 2rem; /* This already maps to block/inline */

Actually — the four-value shorthand margin: top right bottom left maps to physical directions. Use the explicit logical shorthands margin-block and margin-inline to be unambiguous.

What I’d Do On Every Project From the Start

If I’m starting a project from scratch, these are non-negotiable day one decisions:

Define the token layers. Primitive tokens first — the raw values. Semantic tokens second — the intent. Component tokens third — the component-level API. Three layers, defined once in a tokens.css file, referenced everywhere.

Use logical properties exclusively for layout. margin-block, padding-inline, inset-inline-start. Every layout property that relates to content flow uses the logical version from the first line of CSS. Physical properties reserved for the genuine physical cases.

Establish the component variable API pattern. Every component gets a block of custom property declarations at the top of its CSS. The implementation uses those properties. External overrides target those properties at a higher scope. The component’s internal CSS never changes.

@property for animated tokens. Any custom property that will be transitioned or animated gets registered with @property so the browser knows its type.

Starting these four habits early produces a codebase that handles theming, direction switching, responsive adaptation, and component customisation in CSS — where it belongs — rather than in JavaScript, where it creates coupling, performance costs, and unnecessary complexity.

Conclusion

Custom properties and logical properties have been production-ready for years. The browser support is not the barrier — it stopped being the barrier in 2020. The barrier is familiarity, and familiarity comes from use.

Most developers who have used custom properties have used them as static token references. The scoped override pattern, the component API pattern, the animated property pattern, the boolean hack — these are where the feature stops being a convenience and starts being a design primitive.

Most developers who have heard of logical properties have not adopted them because nothing in their current work required RTL support and the feature felt optional. It is not optional on any project that takes internationalisation seriously, and it produces cleaner, more maintainable layout CSS even when direction never changes.

Together, the two features handle a category of complexity that most codebases currently solve with JavaScript — runtime style computation, class toggling, theme context providers, direction detection. Moving that complexity into CSS is not just an architectural preference. It is faster, less coupled to the rendering framework, easier to override, and significantly less code to maintain.

The design problems that seem to require complex solutions often don’t. They require CSS that is used fully.

Resources