| name | css-author |
| description | Modern CSS organization with native @import, @layer cascade control, CSS nesting, design tokens, and element-focused selectors. AUTO-INVOKED when editing .css files. |
| allowed-tools | Read, Write, Edit |
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
1. 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); }
}
}
2. Add Import to main.css
/* In main.css, add to appropriate section */
@import "components/_gallery.css" layer(components);
3. 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 -
@layerbefore@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-sizeon the containing element - Use
container-namewhen multiple containers need targeting - Prefer
min-widthfor progressive enhancement - Use container units (
cqi,cqw) for fluid typography/spacing - Apply container units to children, not the container element itself
- Use
round()withlhunits 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
responsivelayer - Design tokens in
_tokens.css - Consider
@scopefor components with many child rules or donut patterns
Colors
- Colors defined in OKLCH format, not hex or RGB
-
color-scheme: light darkdeclared 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-typewhen children need to adapt - Logical properties used for margins, padding, and borders
-
margin-inline/padding-blockinstead of physical directions -
text-align: startinstead oftext-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:csspasses 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