CSS Author Skill
This skill provides patterns for organizing CSS in modern, maintainable ways without build tools. We leverage native CSS features: @import for modularization, @layer for cascade control, and nesting for readability.
Philosophy
CSS should be:
-
Native - No preprocessors or build steps required
-
Modular - Organized by scope and purpose
-
Predictable - Cascade layers eliminate specificity wars
-
Semantic - Target elements, not class soup
File Organization Hierarchy
styles/ ├── main.css # Entry point - imports everything ├── _reset.css # CSS reset/normalize ├── _tokens.css # Design tokens (custom properties) ├── _layout.css # Site-wide layout (grid, body structure) ├── _components.css # Shared components (buttons, cards) ├── sections/ │ ├── _header.css # Site header/nav │ ├── _footer.css # Site footer │ └── _sidebar.css # Sidebar patterns ├── pages/ │ ├── _home.css # Homepage-specific styles │ ├── _blog.css # Blog listing/post styles │ └── _contact.css # Contact page styles └── components/ ├── _gallery.css # Gallery grid component ├── _tag-list.css # Tag component styles └── _data-table.css # Table wrapper styles
Naming Convention
-
Underscore prefix (_reset.css ): Partial files, imported by main.css
-
No prefix (main.css ): Entry point, linked in HTML
-
Lowercase with hyphens: _tag-list.css , _data-table.css
The Entry Point (main.css )
The main stylesheet declares layers and imports partials:
/* Layer declaration - controls cascade order */ @layer reset, tokens, layout, sections, components, pages, responsive;
/* Reset (lowest priority) */ @import "_reset.css" layer(reset);
/* Design system tokens */ @import "_tokens.css" layer(tokens);
/* Site-wide layout */ @import "_layout.css" layer(layout);
/* Recurring sections */ @import "sections/_header.css" layer(sections); @import "sections/_footer.css" layer(sections); @import "sections/_sidebar.css" layer(sections);
/* Shared components */ @import "_components.css" layer(components); @import "components/_gallery.css" layer(components); @import "components/_tag-list.css" layer(components); @import "components/_data-table.css" layer(components);
/* Page-specific styles */ @import "pages/_home.css" layer(pages); @import "pages/_blog.css" layer(pages); @import "pages/_contact.css" layer(pages);
/* Responsive overrides (highest priority) / @layer responsive { @media (max-width: 768px) { / Mobile overrides */ } }
Design Tokens System
Design tokens are CSS custom properties that provide consistent, themeable values across your design system.
Why Design Tokens?
Design tokens provide:
-
Consistency - Same values used everywhere
-
Maintainability - Change once, apply everywhere
-
Theming - Swap token values for different themes
-
Documentation - Token names describe purpose
Token Categories
Category Purpose Examples
Colors Brand, semantic, surface colors --primary , --error
Spacing Consistent gaps and padding --spacing-sm , --spacing-lg
Typography Font sizes, weights, heights --font-size-lg , --line-height-normal
Effects Shadows, transitions, borders --shadow-md , --transition-normal
Layout Widths, breakpoints --content-width , --sidebar-width
Modern Color Formats
Use OKLCH instead of hex/RGB. OKLCH provides:
-
Perceptually uniform lightness (consistent perceived brightness)
-
Wider color gamut than sRGB
-
Better color interpolation in gradients
-
Easier programmatic color generation
Format Use Case Example
oklch()
Primary format for all colors oklch(55% 0.22 260)
light-dark()
Theme-aware tokens light-dark(oklch(20% 0 0), oklch(95% 0 0))
color-mix()
Blending, opacity color-mix(in oklch, var(--primary), transparent 50%)
Relative colors Variations from base oklch(from var(--primary) calc(l + 0.2) c h)
OKLCH Syntax
/* oklch(lightness chroma hue) / --primary: oklch(55% 0.22 260); / Blue / --success: oklch(65% 0.2 145); / Green / --warning: oklch(75% 0.18 85); / Orange / --error: oklch(55% 0.22 25); / Red */
-
Lightness: 0% (black) to 100% (white)
-
Chroma: 0 (gray) to ~0.4 (vivid) - varies by hue
-
Hue: 0-360 degrees (0=pink, 90=yellow, 180=cyan, 270=blue)
Relative Colors (Derive Variations)
Generate color variations programmatically from a base color:
:root { --primary: oklch(55% 0.22 260);
/* Lighter: increase lightness */ --primary-light: oklch(from var(--primary) calc(l + 0.2) c h);
/* Darker: decrease lightness */ --primary-dark: oklch(from var(--primary) calc(l - 0.15) c h);
/* Muted: reduce chroma */ --primary-muted: oklch(from var(--primary) l calc(c - 0.1) h);
/* Hover: slightly darker and more saturated */ --primary-hover: oklch(from var(--primary) calc(l - 0.08) calc(c + 0.02) h); }
Theme-Aware Colors with light-dark()
Single declarations for both light and dark themes:
:root { color-scheme: light dark; /* Required for light-dark() */
/* Single token handles both themes */ --text: light-dark(oklch(20% 0 0), oklch(95% 0 0)); --surface: light-dark(oklch(100% 0 0), oklch(15% 0.02 260)); --border: light-dark(oklch(90% 0.01 260), oklch(30% 0.02 260)); }
Color Mixing for Opacity/Blending
/* Semi-transparent overlays */ --overlay-light: color-mix(in oklch, black, transparent 95%); --overlay-medium: color-mix(in oklch, black, transparent 90%);
/* Elevated surfaces */ --surface-elevated: color-mix(in oklch, var(--surface), white 5%);
/* Blend two colors */ --accent-blend: color-mix(in oklch, var(--primary), var(--secondary) 30%);
Gradients with Color Space
Specify color space to prevent muddy midtones:
/* Vibrant gradient interpolation */ background: linear-gradient(in oklch, var(--primary), var(--secondary));
/* For hue transitions, use longer path */ background: linear-gradient(in oklch longer hue, oklch(65% 0.25 0), oklch(65% 0.25 360));
Browser Fallbacks
For older browsers, provide hex fallback first:
:root { --primary: #2563eb; /* Fallback for older browsers */ --primary: oklch(55% 0.22 260); }
Automatic Contrast with contrast-color()
The contrast-color() function automatically selects black or white text based on background:
/* Button with any background color */ button { background: var(--primary); color: contrast-color(var(--primary)); }
/* Dynamic accent backgrounds */ [data-accent] { background: var(--accent); color: contrast-color(var(--accent)); }
Combining with light-dark() :
.badge { --bg: light-dark(var(--primary-light), var(--primary-dark)); background: var(--bg); color: contrast-color(var(--bg)); }
Limitations:
-
Returns only black (#000 ) or white (#fff )
-
Uses WCAG 2 algorithm (may not be perceptually optimal for mid-tones)
-
Browser support: Safari Technology Preview only (use as progressive enhancement)
-
Does not guarantee WCAG compliance—verify contrast ratios for critical UI
Best practice: Use contrast-color() for dynamic/user-selected colors. For design system colors, manually define text colors to ensure optimal readability.
Complete Token System
/* _tokens.css / @layer tokens { :root { / Enable light-dark() function */ color-scheme: light dark;
/* ==================== COLORS (OKLCH) ==================== */
/* Hue palette - define once, reuse everywhere */
--hue-primary: 260; /* Blue */
--hue-secondary: 250; /* Slate */
--hue-success: 145; /* Green */
--hue-warning: 85; /* Orange */
--hue-error: 25; /* Red */
--hue-info: 200; /* Cyan */
/* Brand colors with relative variations */
--primary: oklch(55% 0.22 var(--hue-primary));
--primary-hover: oklch(from var(--primary) calc(l - 0.08) calc(c + 0.02) h);
--primary-light: oklch(from var(--primary) calc(l + 0.35) calc(c - 0.12) h);
--secondary: oklch(50% 0.03 var(--hue-secondary));
--secondary-hover: oklch(from var(--secondary) calc(l - 0.1) c h);
/* Semantic colors */
--success: oklch(60% 0.18 var(--hue-success));
--success-light: oklch(from var(--success) calc(l + 0.32) calc(c - 0.1) h);
--warning: oklch(75% 0.16 var(--hue-warning));
--warning-light: oklch(from var(--warning) calc(l + 0.2) calc(c - 0.08) h);
--error: oklch(55% 0.2 var(--hue-error));
--error-light: oklch(from var(--error) calc(l + 0.38) calc(c - 0.12) h);
--info: oklch(55% 0.14 var(--hue-info));
--info-light: oklch(from var(--info) calc(l + 0.38) calc(c - 0.08) h);
/* Theme-aware surface colors */
--background: light-dark(oklch(100% 0 0), oklch(12% 0.02 var(--hue-primary)));
--background-alt: light-dark(oklch(98% 0.005 var(--hue-primary)), oklch(16% 0.02 var(--hue-primary)));
--surface: light-dark(oklch(100% 0 0), oklch(16% 0.02 var(--hue-primary)));
--surface-elevated: light-dark(oklch(100% 0 0), oklch(22% 0.02 var(--hue-primary)));
/* Theme-aware text colors */
--text: light-dark(oklch(20% 0.02 var(--hue-primary)), oklch(96% 0.01 var(--hue-primary)));
--text-muted: light-dark(oklch(45% 0.02 var(--hue-primary)), oklch(65% 0.02 var(--hue-primary)));
--text-inverted: light-dark(oklch(100% 0 0), oklch(10% 0 0));
/* Theme-aware border colors */
--border: light-dark(oklch(90% 0.01 var(--hue-primary)), oklch(28% 0.02 var(--hue-primary)));
--border-strong: light-dark(oklch(82% 0.01 var(--hue-primary)), oklch(38% 0.02 var(--hue-primary)));
/* Theme-aware overlays using color-mix */
--overlay-light: light-dark(
color-mix(in oklch, black, transparent 95%),
color-mix(in oklch, white, transparent 95%)
);
--overlay-medium: light-dark(
color-mix(in oklch, black, transparent 90%),
color-mix(in oklch, white, transparent 90%)
);
--overlay-strong: light-dark(
color-mix(in oklch, black, transparent 80%),
color-mix(in oklch, white, transparent 80%)
);
/* ==================== SPACING ==================== */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--spacing-2xl: 3rem; /* 48px */
--spacing-3xl: 4rem; /* 64px */
/* ==================== TYPOGRAPHY ==================== */
/* Font families */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace;
--font-serif: Georgia, Cambria, "Times New Roman", Times, serif;
/* Font sizes */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 1.875rem; /* 30px */
--font-size-4xl: 2.25rem; /* 36px */
/* Font weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
/* ==================== EFFECTS ==================== */
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
/* ==================== LAYOUT ==================== */
--content-width: 65ch;
--content-width-wide: 80rem;
--sidebar-width: 16rem;
/* Z-index scale */
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 300;
--z-tooltip: 400;
} }
Dark Theme Approach
Recommended: Use light-dark() in the Complete Token System above. This eliminates the need for duplicate token definitions.
User-Controlled Theme Toggle (Legacy Pattern)
For sites with theme toggle UI that override system preference, use CSS :has() to scope token overrides:
/* Force dark mode when user selects dark / :root:has(#theme-dark:checked) { color-scheme: dark; / Triggers light-dark() to use dark values */ }
/* Force light mode when user selects light / :root:has(#theme-light:checked) { color-scheme: light; / Triggers light-dark() to use light values */ }
/* Auto follows system preference (default behavior) */ :root:has(#theme-auto:checked) { color-scheme: light dark; }
Component-Specific Tokens
:root { /* Form tokens */ --form-border: var(--border); --form-focus: var(--primary); --form-invalid: var(--error); --form-input-padding: var(--spacing-sm) var(--spacing-md); --form-input-radius: var(--radius-md);
/* Button tokens */ --button-padding: var(--spacing-sm) var(--spacing-lg); --button-radius: var(--radius-md); --button-primary-bg: var(--primary); --button-primary-text: var(--text-inverted);
/* Card tokens */ --card-padding: var(--spacing-lg); --card-radius: var(--radius-lg); --card-shadow: var(--shadow-sm); --card-bg: var(--surface); }
Token Naming Guidelines
Pattern Example Purpose
--{category}
--primary , --error
Base tokens (no -color suffix)
--{category}-{variant}
--primary-hover , --success-light
Token variations
--{element}-{modifier}
--text-muted , --border-strong
Semantic element tokens
Use semantic names, not literal values:
Avoid Prefer
--blue , --primary-color
--primary
--red , --error-color
--error
--16px
--spacing-md
#2563eb (hex in code) var(--primary)
CSS Layers (@layer )
Why Layers?
Layers provide explicit cascade control regardless of selector specificity:
@layer base, theme, utilities;
@layer utilities { .hidden { display: none !important; } }
@layer base { button { display: inline-block; } }
/* utilities wins over base, even with lower specificity */
Recommended Layer Order
Layer Priority Purpose
reset
Lowest Normalize browser defaults
tokens
Low CSS custom properties
layout
Medium-Low Body grid, main structure
sections
Medium Header, footer, sidebar
components
Medium-High Buttons, cards, form elements
pages
High Page-specific overrides
responsive
Highest Media query adjustments
Layer Benefits
-
No specificity wars - Later layers always win
-
Predictable overrides - Page styles override components
-
Safe imports - Third-party CSS can be isolated
-
Clear organization - Find styles by layer purpose
Custom Element Display Behavior
Custom elements (hyphenated tags like <product-card> ) have specific display quirks that require understanding.
Browser Default: Inline
Browsers treat unknown elements as display: inline , breaking block-level layouts:
<!-- Renders INLINE by default! --> <product-card> <img src="product.jpg" alt="..." /> <h3>Product Name</h3> </product-card>
This causes layout issues because the element doesn't create a block formatting context.
The :not(:defined) Solution
The :not(:defined) pseudo-class matches custom elements that haven't been registered with customElements.define() :
/* In reset layer - catches ALL unregistered custom elements */ :not(:defined) { display: block; }
This is ideal for CSS-only custom elements that will never be registered as Web Components.
Layer Specificity Warning
Critical: Unlayered browser defaults beat layered CSS. Even with @layer reset { ... } , browser defaults can override your styles.
/* May NOT work - layer has lower priority than browser default */ @layer reset { product-card { display: block; } }
/* Solution: :not(:defined) has higher specificity */ :not(:defined) { display: block; }
The :defined Pseudo-Class
For registered Web Components, use :defined to style after JavaScript loads:
/* Hide until component is defined */ product-card:not(:defined) { visibility: hidden; }
/* Show when registered */ product-card:defined { visibility: visible; }
Block vs Inline Custom Elements
Not all custom elements should be block. Consider the content model:
Element Type Display Examples
Container/Section block
product-card , hero-section , card-grid
Badge/Indicator inline-flex
status-badge , tag-item
Icon inline-flex
x-icon
Elements with phrasing: true in elements.json are designed to be inline.
List Styling Patterns
Styling lists reliably requires understanding browser defaults and specificity.
Removing Default Bullets
The most reliable pattern for navigation and card lists:
/* In reset layer */ ul, ol { list-style: none; padding: 0; margin: 0; }
Warning: list-style-type: none alone may not work in all contexts. Use the shorthand list-style: none for reliability.
Accessibility Note
When you remove bullets from a list, screen readers may not announce it as a list in Safari/VoiceOver. Add role="list" to preserve semantics:
<ul role="list"> <li>Item with no bullet but announced as list</li> </ul>
Custom Markers with ::marker
For custom bullets, use the ::marker pseudo-element:
li::marker { color: var(--primary); content: "→ "; }
/* For specific lists */ ul[data-style="checkmarks"] li::marker { content: "✓ "; color: var(--success); }
Numbered Lists with Custom Styling
ol { counter-reset: list-counter; list-style: none; }
ol li { counter-increment: list-counter; }
ol li::before { content: counter(list-counter) ". "; color: var(--primary); font-weight: var(--font-weight-semibold); }
When to Use Each Pattern
Pattern Use Case
list-style: none
Navigation, card grids, tab lists
::marker
Prose lists with custom bullet style
counter()
Numbered steps, ordered lists with custom numbers
CSS Scope (@scope )
The @scope at-rule limits selector reach to a specific DOM subtree without increasing specificity. While @layer controls cascade order, @scope controls where selectors can match.
Why @scope ?
Without @scope With @scope
Selectors leak globally Selectors limited to subtree
Need long descendant chains Short selectors, explicit boundaries
High specificity for isolation Low specificity preserved
Basic Syntax
@scope (product-card) { /* These only match inside <product-card> */ img { border-radius: var(--radius-md); }
h3 { font-size: var(--font-size-lg); } }
The scoping root (product-card ) doesn't add to selector specificity—img remains (0,0,1) .
The :scope Pseudo-Class
Reference the scoping root itself:
@scope (blog-card) { :scope { /* Styles the <blog-card> element */ display: grid; gap: var(--spacing-md); }
h3 { /* Styles <h3> inside <blog-card> */ margin: 0; } }
Donut Scope Pattern
Exclude nested sections with a lower boundary using to :
/* Style card chrome, but not user content inside / @scope (blog-card) to (.card-content) { img { / Only matches images in card header/footer, not in content */ border: 2px solid var(--border); } }
Use cases for donut scope:
-
Style component wrapper but not slotted content
-
Style card header/footer but not body
-
Apply theme to shell but let content inherit differently
@scope with @layer
Combine scope and layers for full control:
@layer components { @scope (product-card) { :scope { container-type: inline-size; padding: var(--spacing-lg); }
img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
}
@container (min-width: 400px) {
:scope {
display: grid;
grid-template-columns: 200px 1fr;
}
}
} }
@scope vs Element Selectors
Both work for our custom element approach:
/* Element selector (our typical pattern) */ product-card { display: grid; }
product-card img { border-radius: var(--radius-md); }
/* @scope equivalent - cleaner for many child rules */ @scope (product-card) { :scope { display: grid; }
img { border-radius: var(--radius-md); }
h3 { } p { } footer { } }
When to use @scope :
-
Component has many child element rules
-
Need donut scope to exclude nested content
-
Want to group all component styles in one block
When element selectors suffice:
-
Simple components with few rules
-
Already using nesting effectively
Prelude-less Scope (Inline Styles)
In component HTML, scope without a selector:
<product-card> <style> @scope { :scope { display: grid; } img { border-radius: var(--radius-md); } } </style> <img src="..." alt="..." /> <h3>Product Name</h3> </product-card>
The scope automatically targets the parent element.
Important Limitation
@scope limits selector reach, not inheritance. Inherited properties like color still cascade into excluded donut holes:
@scope (.card) to (.content) { :scope { color: blue; /* .content still inherits blue! */ } }
To prevent inheritance, reset properties explicitly on the excluded element.
Browser Support
-
Chrome 118+, Edge 118+, Safari 17.4+, Firefox 146+
-
Wide support (90%+) - safe to use without fallbacks
Native CSS Nesting
Modern browsers support CSS nesting, reducing repetition:
/* Without nesting */ nav { } nav ul { } nav a { } nav a:hover { }
/* With nesting */ nav { & ul { display: flex; gap: var(--spacing-lg); }
& a { padding: var(--spacing-sm) var(--spacing-md);
&:hover {
background: var(--overlay-light);
}
&[aria-current="page"] {
background: var(--overlay-strong);
}
} }
Nesting Rules
-
Use & for clarity - Always prefix nested selectors with &
-
Limit depth - No more than 3-4 levels deep
-
Keep related styles together - Element and its states
-
Avoid over-nesting - If selectors get complex, flatten
Nesting with Media Queries
Media queries can be nested inside selectors:
header { padding: var(--spacing-lg);
@media (max-width: 768px) { padding: var(--spacing-md); } }
Element-Focused CSS (Classless)
Target Semantic HTML
Instead of inventing classes, style semantic elements:
/* Avoid */ .header-nav { } .nav-list { } .nav-link { }
/* Prefer */ header nav { } header nav ul { } header nav a { }
Custom Elements as Styling Hooks
Custom elements provide semantic styling targets without classes:
/* Instead of .form-group { } */ form-field { }
/* Instead of .product-card { } */ product-card { }
/* Instead of .table-wrapper { } */ table-wrapper { }
When Classes Are Appropriate
Use classes sparingly for:
Use Case Example
Multi-variant components .card , .card-featured
View transition names .vt-card-1 (when data-* insufficient)
Third-party integration Classes required by libraries
Never use classes for state. Use data-* attributes instead.
Scope Hierarchy
Level Scope Contents
Tokens Entire site Colors, spacing, typography, effects
Layout Body structure Grid areas, view transitions, body rules
Sections Recurring site parts Header, footer, sidebar, navigation
Components Reusable blocks Cards, buttons, forms, tables, tags
Pages Single page types Homepage hero, blog post, contact form
When to Create a New File
Scenario Action
New custom element Create components/_element-name.css
New page type with unique styles Create pages/_page-name.css
New recurring section Create sections/_section-name.css
New design token category Extend _tokens.css
Adding a New CSS File
- Create the Partial
/* components/_gallery.css */ @layer components { gallery-grid { display: grid; gap: var(--spacing-md);
&[data-columns="2"] { grid-template-columns: repeat(2, 1fr); }
&[data-columns="3"] { grid-template-columns: repeat(3, 1fr); }
&[data-columns="4"] { grid-template-columns: repeat(4, 1fr); }
} }
- Add Import to main.css
/* In main.css, add to appropriate section */ @import "components/_gallery.css" layer(components);
- File Template
Every partial should follow this structure:
/* components/_example.css / @layer components { / Element styles / example-element { / Base styles */
/* State variants via data attributes */
&[data-state="active"] { }
/* Nested elements */
& .inner { }
/* Responsive adjustments */
@media (max-width: 768px) { }
} }
CSS Import Performance
Browser Behavior
Modern browsers handle @import efficiently:
-
Parallel fetching when imports are at the start
-
Caching of individual files
-
No render-blocking beyond the cascade order
Best Practices
-
All imports at the top - Before any other CSS
-
Layer declaration first - @layer before @import
-
Use HTTP/2 - Multiplexing handles multiple files well
-
Consider concatenation for production if needed
When to Consolidate
For very high-traffic sites, you may want to concatenate CSS:
Simple concatenation for production
cat styles/_reset.css styles/_tokens.css styles/_layout.css > styles/bundle.css
But for most projects, native imports work well.
Responsive Design Pattern
Mobile-First vs Desktop-First
We use desktop-first with max-width queries, grouped in the responsive layer:
@layer responsive { @media (max-width: 1024px) { /* Tablet adjustments */ }
@media (max-width: 768px) { /* Mobile adjustments */ }
@media (max-width: 480px) { /* Small mobile adjustments */ } }
Breakpoint Tokens
Define breakpoints as documentation (CSS can't use variables in media queries):
/* _tokens.css / :root { / Breakpoints (for reference - use literal values in @media) / / --breakpoint-xl: 1280px; / / --breakpoint-lg: 1024px; / / --breakpoint-md: 768px; / / --breakpoint-sm: 480px; */ }
Container Queries (@container )
Container queries enable component-scoped responsive design. Unlike media queries (which respond to viewport size), container queries respond to the size of a parent container.
Why Container Queries?
Media Queries Container Queries
Respond to viewport Respond to container
Global breakpoints Component-specific
Same component, same layout everywhere Same component adapts to context
Use case: A card component that displays horizontally in a wide sidebar but stacks vertically in a narrow sidebar—without knowing where it's placed.
Defining a Container
Use container-type to establish a containment context:
/* Any element can be a container / sidebar-panel { container-type: inline-size; / Width-based queries / container-name: sidebar; / Optional: name for targeting */ }
/* Shorthand / main-content { container: content / inline-size; / name / type */ }
Container Types
Type Queries On Use When
inline-size
Width only Most common - responsive layouts
size
Width and height Rare - when height matters
normal
No size queries Style queries only
Recommendation: Use inline-size for 99% of cases.
Writing Container Queries
/* Query any ancestor container */ @container (min-width: 400px) { blog-card { display: grid; grid-template-columns: 200px 1fr; } }
/* Query a specific named container */ @container sidebar (max-width: 300px) { blog-card { flex-direction: column; } }
Container Query Units
Container-relative units for truly fluid components:
Unit Meaning
cqw
1% of container width
cqh
1% of container height
cqi
1% of container inline size
cqb
1% of container block size
cqmin
Smaller of cqi or cqb
cqmax
Larger of cqi or cqb
Fluid Typography with Container Units
blog-card h3 { /* Font scales with container width, respects user zoom */ font-size: clamp(1rem, 0.875rem + 0.5cqi, 1.5rem); }
Rhythm-Aligned Spacing
Combine container units with lh (line-height) units for vertical rhythm:
blog-card { /* Gap scales with container but rounds to quarter-line increments */ --gap: round(up, 2cqi, 0.25lh); gap: var(--gap); }
The round() function ensures spacing aligns to the typographic grid.
Important Limitation
Container units cannot measure the element they're applied to. This would create a circular dependency. Use nested elements or wrapper patterns:
/* WRONG - card can't size based on its own container / product-card { container-type: inline-size; padding: 2cqi; / Measures parent, not self! */ }
/* CORRECT - children measure the card container */ product-card { container-type: inline-size; }
product-card > * { padding: 2cqi; /* Now measures product-card */ }
Container Queries with Layers
Container queries integrate naturally with the layer system:
@layer components { /* Define containers at the component wrapper level */ card-container { container-type: inline-size; }
/* Base card styles */ blog-card { display: flex; flex-direction: column; gap: var(--spacing-md); }
/* Container-responsive layout */ @container (min-width: 500px) { blog-card { flex-direction: row; }
blog-card img {
width: 40%;
flex-shrink: 0;
}
} }
Pattern: Self-Contained Responsive Components
Make components that adapt without external configuration:
/* components/_product-card.css / @layer components { product-card { / The card IS its own container */ container-type: inline-size;
display: grid;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
/* Compact layout (narrow) */ @container (max-width: 299px) { product-card { text-align: center;
& img {
margin-inline: auto;
max-width: 150px;
}
}
}
/* Standard layout (medium) */ @container (min-width: 300px) and (max-width: 499px) { product-card { grid-template-columns: 1fr; } }
/* Wide layout (large) */ @container (min-width: 500px) { product-card { grid-template-columns: 200px 1fr; grid-template-rows: auto 1fr auto;
& img {
grid-row: 1 / -1;
}
}
} }
Container Queries vs Media Queries
Use both—they serve different purposes:
@layer components { blog-card { container-type: inline-size; }
/* Container query: responds to where card is placed */ @container (min-width: 400px) { blog-card { grid-template-columns: 150px 1fr; } } }
@layer responsive { /* Media query: global layout changes / @media (max-width: 768px) { .card-grid { grid-template-columns: 1fr; / Stack cards on mobile */ } } }
Nesting Container Queries
Container queries can be nested inside element selectors:
sidebar-panel { container-type: inline-size;
& blog-card { padding: var(--spacing-md);
@container (min-width: 350px) {
padding: var(--spacing-lg);
display: grid;
grid-template-columns: 100px 1fr;
}
} }
Container Query Checklist
When implementing container queries:
-
Set container-type: inline-size on the containing element
-
Use container-name when multiple containers need targeting
-
Prefer min-width for progressive enhancement
-
Use container units (cqi , cqw ) for fluid typography/spacing
-
Apply container units to children, not the container element itself
-
Use round() with lh units for rhythm-aligned spacing
-
Keep container queries in the same layer as component styles
-
Test components in various container widths
CSS Subgrid
Subgrid allows nested elements to participate in their parent's grid, enabling alignment across nested structures without duplicating track definitions.
Why Subgrid?
Without Subgrid With Subgrid
Nested grids are independent Child inherits parent's tracks
Must duplicate track sizes Single source of truth
Alignment breaks across nesting Perfect alignment across levels
Basic Subgrid Pattern
/* Parent grid */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-lg); }
/* Card spans parent columns, subgrids rows / .card { display: grid; grid-template-rows: auto 1fr auto; / header, content, footer */ }
/* With subgrid: all cards align their internal rows / .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto 1fr auto; / Define rows at parent level */ gap: var(--spacing-lg); }
.card { display: grid; grid-row: span 3; grid-template-rows: subgrid; /* Inherit parent's row tracks */ }
Subgrid for Form Alignment
Align labels and inputs across form fields:
form { display: grid; grid-template-columns: max-content 1fr; gap: var(--spacing-md); }
form-field { display: grid; grid-column: span 2; grid-template-columns: subgrid; }
form-field label { grid-column: 1; }
form-field input { grid-column: 2; }
Subgrid for Card Components
Cards with aligned headers, content, and footers:
/* Define consistent structure at grid level / product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-auto-rows: auto 1fr auto; / image, details, actions */ gap: var(--spacing-lg); }
product-card { display: grid; grid-row: span 3; grid-template-rows: subgrid; gap: var(--spacing-md); }
product-card img { grid-row: 1; } product-card .details { grid-row: 2; } product-card .actions { grid-row: 3; }
Subgrid in Both Directions
Inherit both column and row tracks:
.parent { display: grid; grid-template-columns: 1fr 2fr 1fr; grid-template-rows: auto 1fr auto; }
.child { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid; }
Named Lines with Subgrid
Named lines pass through to subgrid:
.layout { display: grid; grid-template-columns: [full-start] 1fr [content-start] minmax(0, 60ch) [content-end] 1fr [full-end]; }
.content { grid-column: full-start / full-end; display: grid; grid-template-columns: subgrid; }
/* Child can use parent's named lines */ .content h1 { grid-column: content-start / content-end; }
.content .full-bleed { grid-column: full-start / full-end; }
When to Use Subgrid
Use Case Benefit
Card grids Aligned headers/footers across cards
Form layouts Labels and inputs align vertically
Data tables Column alignment in complex cells
Multi-level navigation Consistent column widths
Article layouts Full-bleed elements with named lines
Browser Support Note
Subgrid has good modern browser support (90%+). For older browsers, the fallback is a regular nested grid which may not align perfectly but remains functional.
CSS Logical Properties
Logical properties replace physical direction properties (left, right, top, bottom) with flow-relative alternatives. This enables layouts that automatically adapt to different writing modes and text directions.
Why Logical Properties?
Physical Properties Logical Properties
Fixed to screen edges Adapt to text direction
Break in RTL languages Work in any writing mode
Require RTL overrides Automatically flip
margin-left: 1rem
margin-inline-start: 1rem
Benefits:
-
Internationalization - Layouts work in Arabic, Hebrew, and other RTL languages
-
Future-proof - Vertical writing modes (CJK) work automatically
-
Consistency - One codebase for all languages
-
Semantic - Properties describe intent, not position
The Logical Model
CSS logical properties use two axes:
Axis Direction Physical Equivalent
Block Vertical (in LTR/RTL) Top ↔ Bottom
Inline Horizontal (in LTR/RTL) Left ↔ Right
Each axis has two edges:
Edge Block Axis Inline Axis (LTR) Inline Axis (RTL)
Start Top Left Right
End Bottom Right Left
Property Mappings
Margins
Physical Logical
margin-top
margin-block-start
margin-bottom
margin-block-end
margin-left
margin-inline-start
margin-right
margin-inline-end
Shorthand properties:
/* Two values: start and end / margin-block: 1rem 2rem; / top: 1rem, bottom: 2rem / margin-inline: 1rem 2rem; / left: 1rem (LTR), right: 1rem (RTL) */
/* Single value: both start and end / margin-block: 1rem; / top and bottom / margin-inline: 1rem; / left and right */
Padding
Same pattern as margins:
padding-block: var(--spacing-lg); padding-inline: var(--spacing-md);
/* Individual sides */ padding-block-start: var(--spacing-lg); padding-inline-end: var(--spacing-sm);
Sizing
Physical Logical
width
inline-size
height
block-size
min-width
min-inline-size
max-height
max-block-size
blog-card { inline-size: 100%; max-inline-size: 40rem; min-block-size: 200px; }
Positioning
Physical Logical
top
inset-block-start
bottom
inset-block-end
left
inset-inline-start
right
inset-inline-end
Shorthand:
/* All four sides / inset: 0; / Same as top: 0; right: 0; bottom: 0; left: 0; */
/* Block and inline axes / inset-block: 0; / top and bottom / inset-inline: 0; / left and right */
Borders
/* Border on one logical side */ border-inline-start: 3px solid var(--primary);
/* Border radius / border-start-start-radius: var(--radius-lg); / top-left in LTR / border-end-start-radius: var(--radius-lg); / bottom-left in LTR */
Text Alignment
Physical Logical
text-align: left
text-align: start
text-align: right
text-align: end
Common Patterns
Centering with Logical Properties
/* Center horizontally (works in RTL) */ blog-card { margin-inline: auto; max-inline-size: 40rem; }
Icon + Text Spacing
/* Space between icon and text, flips in RTL */ button svg { margin-inline-end: var(--spacing-sm); }
Sidebar Layout
/* Sidebar on the start edge (left in LTR, right in RTL) */ main-layout { display: grid; grid-template-columns: 250px 1fr; }
sidebar-panel { border-inline-end: 1px solid var(--border); padding-inline-end: var(--spacing-lg); }
Card with Accent Border
/* Accent border on start edge */ blog-card[data-featured] { border-inline-start: 4px solid var(--primary); padding-inline-start: var(--spacing-lg); }
Migration Guide
When converting existing CSS:
/* Before */ .card { margin-left: 1rem; margin-right: 1rem; padding-top: 2rem; padding-bottom: 1rem; border-left: 3px solid blue; text-align: left; }
/* After */ .card { margin-inline: 1rem; padding-block: 2rem 1rem; border-inline-start: 3px solid blue; text-align: start; }
When to Keep Physical Properties
Some properties should remain physical:
Property Keep Physical When
top , left , etc. Fixed position relative to viewport
transform
Animations that shouldn't flip
box-shadow
Light source should stay consistent
background-position
Image positioning shouldn't flip
/* Physical: shadow direction stays consistent */ blog-card { box-shadow: 2px 2px 8px oklch(0% 0 0 / 0.15); }
/* Logical: border flips with text direction */ blog-card { border-inline-start: 3px solid var(--primary); }
Integration with Design Tokens
Define spacing tokens and use them with logical properties:
/* _tokens.css */ :root { --spacing-xs: 0.25rem; --spacing-sm: 0.5rem; --spacing-md: 1rem; --spacing-lg: 1.5rem; --spacing-xl: 2rem; }
/* Component using logical properties with tokens */ article { padding-block: var(--spacing-xl); padding-inline: var(--spacing-lg); margin-block-end: var(--spacing-lg); }
Browser Support
Logical properties have excellent browser support (95%+). For older browsers:
/* Fallback pattern (only if supporting very old browsers) / blog-card { margin-left: 1rem; / Fallback / margin-inline-start: 1rem; / Modern browsers */ }
Example: Complete Component File
/* components/_blog-card.css */ @layer components { blog-card { display: grid; gap: var(--spacing-md); padding: var(--spacing-lg); background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); transition: box-shadow var(--transition-normal);
/* Hover effect */
&:hover {
box-shadow: var(--shadow-md);
}
/* Featured variant */
&[data-featured] {
border-inline-start: 4px solid var(--primary);
}
/* Child elements */
& h3 {
margin: 0;
font-size: var(--font-size-lg);
}
& time {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
& p {
margin: 0;
line-height: var(--line-height-relaxed);
}
/* Responsive */
@media (max-width: 768px) {
padding: var(--spacing-md);
}
} }
CSS Baseline
Baseline defines which CSS features are available across all major browsers. Our linter warns when using features outside Baseline Newly available status.
Baseline Tiers
Status Meaning Our Approach
Widely available 30+ months in all browsers Use freely
Newly available Recently in all browsers Use freely (our threshold)
Limited availability Not in all browsers Requires @supports
Progressive Enhancement for Non-Baseline
Features outside Baseline must be wrapped in @supports :
/* Base: Baseline-safe fallback */ p { word-break: break-word; }
/* Enhancement: non-Baseline feature */ @supports (text-wrap: pretty) { p { text-wrap: pretty; } }
The linter allows non-Baseline CSS inside @supports blocks.
Common Non-Baseline Features
Some features we document may not yet be Baseline. Always check and use @supports :
/* contrast-color() - Safari Tech Preview only */ @supports (color: contrast-color(red)) { .dynamic-bg { color: contrast-color(var(--bg)); } }
/* text-wrap: pretty - recently Baseline */ @supports (text-wrap: pretty) { article p { text-wrap: pretty; } }
Checking Baseline Status
-
web.dev/baseline - Feature status lookup
-
caniuse.com - Detailed browser support
-
Run npm run lint:css
-
Linter warns on non-Baseline features
Checklist for CSS Architecture
When setting up or reviewing CSS:
Structure
-
Layer declaration at top of main.css
-
All imports use layer() syntax
-
Files organized by scope (tokens, layout, sections, components, pages)
-
No classes used for state (use data-* attributes)
-
Custom elements used as styling hooks
-
Nesting limited to 3-4 levels
-
Responsive styles in responsive layer
-
Design tokens in _tokens.css
-
Consider @scope for components with many child rules or donut patterns
Colors
-
Colors defined in OKLCH format, not hex or RGB
-
color-scheme: light dark declared in :root
-
Theme-aware tokens use light-dark() function
-
Color variations use relative colors (not separate tokens)
-
Gradients specify color space: linear-gradient(in oklch, ...)
-
Hex fallback provided before OKLCH for older browsers (if needed)
Layout
-
Container queries used for component-scoped responsiveness
-
Components define container-type when children need to adapt
-
Logical properties used for margins, padding, and borders
-
margin-inline / padding-block instead of physical directions
-
text-align: start instead of text-align: left
-
Physical properties only where semantically appropriate (shadows, transforms)
Baseline
-
Non-Baseline features wrapped in @supports
-
Baseline-safe fallback provided before enhancement
-
npm run lint:css passes without baseline warnings
Skills to Consider Before Writing
When authoring CSS, consider invoking these related skills:
CSS Feature Invoke Skill Why
Animations, transitions animation-motion Proper keyframes, scroll-driven effects, reduced-motion
Print styles (@media print) print-styles Print-specific layout, page breaks, hiding nav
Icon styling icons Use <x-icon> component, not inline SVG
Dark/light themes data-attributes State via data-theme , not classes
Responsive images responsive-images Image sizing, aspect ratios, art direction
When Styling Components with Icons
When styling buttons, toggles, or UI elements that need icons, ensure the HTML uses <x-icon> :
/* Styling icons is simple when using x-icon */ button x-icon { color: currentColor; }
button:hover x-icon { color: var(--primary); }
See the icons skill before adding any visual indicators to HTML.
Related Skills
-
layout-grid - Fluid grid systems, responsive columns, resolution-independent layouts
-
typography - Type scale, hierarchy, rhythm, text-wrap, font pairing
-
animation-motion - CSS animations, transitions, and scroll-driven effects
-
print-styles - Write print-friendly CSS using @media print
-
icons - Lucide icon library with <x-icon> Web Component
-
data-attributes - Using data-* attributes for state and variants
-
xhtml-author - Write valid XHTML-strict HTML5 markup
-
responsive-images - Modern responsive image techniques
-
progressive-enhancement - HTML-first development with CSS-only interactivity