# zudo-css > Pragmatic CSS knowledge for AI --- # INBOX > Source: https://takazudomodular.com/pj/zcss/docs/inbox Drafts and unsorted notes. Articles here will be moved to a proper category once reviewed. --- # Interactive > Source: https://takazudomodular.com/pj/zcss/docs/interactive Hover/focus/active states, transitions, animations, scroll behaviors, and interactive patterns. --- # Layout > Source: https://takazudomodular.com/pj/zcss/docs/layout CSS layout techniques: Flexbox, Grid, positioning, spacing, sizing, and composition strategies. --- # Methodology > Source: https://takazudomodular.com/pj/zcss/docs/methodology CSS architecture strategies: BEM, CSS Modules, component-first, design tokens, and cascade layers. --- # Overview > Source: https://takazudomodular.com/pj/zcss/docs/overview Project-level conventions for writing and maintaining CSS Best Practices documentation. These guides define how articles are structured, formatted, and styled so that every page in this site stays consistent. --- # Responsive > Source: https://takazudomodular.com/pj/zcss/docs/responsive Container queries, fluid design, media queries, and responsive patterns. --- # Styling > Source: https://takazudomodular.com/pj/zcss/docs/styling Visual styling techniques including color, shadows, borders, and effects. --- # Typography > Source: https://takazudomodular.com/pj/zcss/docs/typography Font sizing, line clamping, vertical rhythm, text overflow, and typographic patterns. --- # Form Control Styling > Source: https://takazudomodular.com/pj/zcss/docs/interactive/forms-and-accessibility/form-control-styling ## The Problem Form elements — checkboxes, radio buttons, range sliders, textareas — have historically been the hardest parts of the web to style. Their rendering is deeply tied to the operating system, and browsers give CSS limited control over their appearance. As a result, developers reach for JavaScript libraries, custom toggle components built from `` elements, or complex CSS hacks involving hidden inputs with sibling selectors. These workarounds bloat the bundle, break native accessibility, and diverge from the underlying HTML semantics. Modern CSS now provides native properties to brand and customize form controls without replacing them. ## The Solution Four modern CSS properties replace most JavaScript-based form customization: - **`accent-color`** — Themes native checkboxes, radios, range sliders, and progress bars with a single declaration. The browser handles contrast automatically. - **`appearance: none`** — Strips the OS-native rendering of a form control, giving you a blank canvas to style from scratch with CSS. - **`field-sizing: content`** — Makes textareas and inputs automatically size to their content, eliminating the need for JavaScript auto-resize scripts. - **`caret-color`** — Changes the color of the blinking text cursor in inputs and textareas, providing a subtle branding touch. ### Core Principles Use `accent-color` as your first tool — it requires zero custom CSS beyond a single property and preserves full accessibility. Only reach for `appearance: none` when you need a design that goes beyond what `accent-color` can express. When you do use `appearance: none`, always restore it inside `@media (forced-colors: active)` so the control remains visible in Windows High Contrast mode. Default Browser Styling Newsletter Option A Option B Volume Progress 70% With accent-color Newsletter Option A Option B Volume Progress 70% `} css={` .accent-demo { padding: 1.5rem; } .accent-heading { font-size: 0.8125rem; font-weight: 700; margin: 0 0 0.75rem; color: hsl(215 15% 45%); } .branded-heading { margin-top: 1.25rem; color: hsl(262 80% 50%); } .accent-row { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } .branded-row { accent-color: hsl(262 80% 50%); } .accent-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: hsl(215 15% 30%); cursor: pointer; } input[type="range"] { width: 80px; } progress { height: 0.5rem; width: 80px; } `} /> One line of CSS — `accent-color: hsl(262 80% 50%)` — themes every native control in the container. The browser automatically adjusts the checkmark and radio dot colors for contrast against the accent background. Enable notifications Accept terms Subscribe to updates `} css={` .custom-checkbox-demo { display: flex; flex-direction: column; gap: 0.875rem; padding: 1.5rem; } .custom-check { display: flex; align-items: center; gap: 0.625rem; cursor: pointer; font-size: 0.875rem; color: hsl(215 15% 25%); } .custom-check-input { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 0.25rem; background: hsl(0 0% 100%); cursor: pointer; display: grid; place-content: center; transition: background-color 0.15s ease, border-color 0.15s ease; flex-shrink: 0; } .custom-check-input::before { content: ""; width: 0.65rem; height: 0.65rem; transform: scale(0); transition: transform 0.12s ease-in-out; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); background: hsl(0 0% 100%); } .custom-check-input:checked { background: hsl(262 80% 50%); border-color: hsl(262 80% 50%); } .custom-check-input:checked::before { transform: scale(1); } .custom-check-input:focus-visible { outline: 2px solid hsl(262 80% 50%); outline-offset: 2px; } .custom-check-input:hover { border-color: hsl(262 60% 60%); } @media (forced-colors: active) { .custom-check-input { appearance: auto; } } `} /> The `appearance: none` declaration strips the native checkbox rendering, then CSS rebuilds it: a border for the box, a `clip-path` polygon for the checkmark on `::before`, and `transform: scale()` for the animation between unchecked and checked states. The `@media (forced-colors: active)` block restores `appearance: auto` so the checkbox remains functional in Windows High Contrast mode. Select a plan Free Pro Enterprise `} css={` .custom-radio-demo { border: none; padding: 1.5rem; margin: 0; display: flex; flex-direction: column; gap: 0.75rem; } .custom-radio-legend { font-size: 0.8125rem; font-weight: 700; color: hsl(215 15% 35%); margin-bottom: 0.25rem; } .custom-radio { display: flex; align-items: center; gap: 0.625rem; cursor: pointer; font-size: 0.875rem; color: hsl(215 15% 25%); } .custom-radio-input { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 50%; background: hsl(0 0% 100%); cursor: pointer; display: grid; place-content: center; transition: border-color 0.15s ease; flex-shrink: 0; } .custom-radio-input::before { content: ""; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: hsl(262 80% 50%); transform: scale(0); transition: transform 0.15s ease-in-out; } .custom-radio-input:checked { border-color: hsl(262 80% 50%); } .custom-radio-input:checked::before { transform: scale(1); } .custom-radio-input:focus-visible { outline: 2px solid hsl(262 80% 50%); outline-offset: 2px; } .custom-radio-input:hover { border-color: hsl(262 60% 60%); } @media (forced-colors: active) { .custom-radio-input { appearance: auto; } } `} /> Custom radios follow the same pattern as checkboxes: `appearance: none` strips the native rendering, a `::before` pseudo-element draws the inner dot, and `transform: scale()` smoothly animates the selection. The circular `border-radius: 50%` and smaller inner dot preserve the conventional radio button visual language so users immediately recognize the control. Fixed height (default) This textarea has a fixed height. If you type more text than fits, it scrolls internally. Users cannot see all their content at once. field-sizing: content This textarea grows with its content. No JavaScript needed — the browser handles resizing natively. Try typing more text here to see it expand. `} css={` .field-sizing-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; padding: 1.5rem; } .field-sizing-col { display: flex; flex-direction: column; gap: 0.5rem; } .field-sizing-label { font-size: 0.75rem; font-weight: 700; color: hsl(215 15% 45%); } .field-sizing-label-new { color: hsl(150 60% 35%); } .field-sizing-textarea { font-family: inherit; font-size: 0.8125rem; line-height: 1.5; padding: 0.625rem 0.75rem; border: 1.5px solid hsl(215 20% 80%); border-radius: 0.375rem; color: hsl(215 15% 25%); background: hsl(0 0% 100%); resize: vertical; transition: border-color 0.15s ease; } .field-sizing-textarea:focus { border-color: hsl(262 80% 50%); outline: 2px solid transparent; box-shadow: 0 0 0 3px hsl(262 80% 50% / 0.15); } .fixed-textarea { height: 5rem; } .auto-textarea { field-sizing: content; min-height: 3rem; max-height: 12rem; } `} /> The `field-sizing: content` property eliminates the need for JavaScript auto-resize libraries. The textarea expands as the user types and shrinks when content is removed. Use `min-height` and `max-height` to set bounds so the textarea does not collapse to a single line or grow to fill the entire page. **Browser support note:** `field-sizing` is supported in Chrome 123+ and Edge 123+ (released March 2024). Firefox and Safari do not yet support it. Use it as a progressive enhancement — the textarea falls back to its default fixed size in unsupported browsers. Create Account Full Name Email Bio I agree to the terms Subscribe to newsletter Create Account `} css={` .themed-form { --form-accent: hsl(262 80% 50%); --form-accent-hover: hsl(262 80% 42%); --form-accent-light: hsl(262 80% 50% / 0.15); --form-border: hsl(215 20% 82%); --form-text: hsl(215 15% 20%); --form-text-muted: hsl(215 15% 50%); padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; max-width: 360px; } .themed-form-title { font-size: 1.125rem; font-weight: 700; color: var(--form-text); margin: 0 0 0.25rem; } .themed-field { display: flex; flex-direction: column; gap: 0.375rem; } .themed-label { font-size: 0.8125rem; font-weight: 600; color: var(--form-text); } .themed-input { font-family: inherit; font-size: 0.875rem; padding: 0.5rem 0.75rem; border: 1.5px solid var(--form-border); border-radius: 0.375rem; color: var(--form-text); background: hsl(0 0% 100%); caret-color: var(--form-accent); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .themed-input::placeholder { color: var(--form-text-muted); } .themed-input:focus { border-color: var(--form-accent); outline: 2px solid transparent; box-shadow: 0 0 0 3px var(--form-accent-light); } .themed-textarea { resize: vertical; min-height: 4rem; } .themed-checks { display: flex; flex-direction: column; gap: 0.5rem; accent-color: var(--form-accent); } .themed-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--form-text); cursor: pointer; } .themed-submit { background: var(--form-accent); color: hsl(0 0% 100%); font-family: inherit; font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border: none; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s ease; } .themed-submit:hover { background: var(--form-accent-hover); } .themed-submit:focus-visible { outline: 2px solid var(--form-accent); outline-offset: 2px; } .themed-submit:active { transform: scale(0.98); } `} /> This form combines all four properties into a cohesive branded experience: `accent-color` themes the native checkboxes, `caret-color` matches the blinking cursor to the brand purple, and custom focus rings using `box-shadow` provide a consistent focus treatment across text inputs and textareas. The submit button follows the same color palette with proper `:hover`, `:focus-visible`, and `:active` states. ## Code Examples ### Quick Branding with accent-color ```css form { accent-color: hsl(262 80% 50%); } ``` This single line themes every checkbox, radio, range slider, and progress bar inside the form. No additional selectors needed. ### Custom Checkbox from Scratch ```css .checkbox { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 0.25rem; display: grid; place-content: center; cursor: pointer; } .checkbox::before { content: ""; width: 0.65rem; height: 0.65rem; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); background: white; transform: scale(0); transition: transform 0.12s ease-in-out; } .checkbox:checked { background: hsl(262 80% 50%); border-color: hsl(262 80% 50%); } .checkbox:checked::before { transform: scale(1); } /* Restore native appearance in Windows High Contrast mode */ @media (forced-colors: active) { .checkbox { appearance: auto; } } ``` ### Auto-Sizing Textarea ```css .auto-textarea { field-sizing: content; min-height: 3rem; max-height: 20rem; } ``` ### Coordinated Form Variables ```css .form { --accent: hsl(262 80% 50%); --accent-ring: hsl(262 80% 50% / 0.15); accent-color: var(--accent); caret-color: var(--accent); } .form input:focus, .form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); outline: 2px solid transparent; } ``` ## Common AI Mistakes - **Replacing native controls with `` elements**: Building fake checkboxes and radios from `` and `` instead of using `` with `appearance: none`. The fake controls lack keyboard navigation, form submission, and screen reader semantics. - **Forgetting `@media (forced-colors: active)`**: Custom checkboxes and radios built with `appearance: none` become invisible in Windows High Contrast mode. Always restore `appearance: auto` inside a `forced-colors` media query. - **Using JavaScript for auto-expanding textareas**: Adding resize observers or input event listeners when `field-sizing: content` handles it natively (with graceful degradation). - **Overriding all form styles when `accent-color` suffices**: Writing dozens of lines of custom checkbox CSS when the design only needs a brand color applied to the native control. - **Not providing focus indicators on custom controls**: Stripping `appearance` without adding `:focus-visible` styles, leaving keyboard users with no visible focus state. - **Hardcoding colors instead of using CSS custom properties**: Making form theming inflexible by scattering color values throughout selectors instead of defining a single set of variables. ## When to Use - **`accent-color`**: When you need to brand native form controls and the design does not require a custom shape or layout. This is the right default choice. - **`appearance: none`**: When the design demands a fully custom checkbox, radio, or select appearance that `accent-color` cannot express. Always pair with `forced-colors` restoration. - **`field-sizing: content`**: When textareas or text inputs should grow with their content. Use as progressive enhancement with `min-height` / `max-height` bounds. - **`caret-color`**: When the blinking cursor should match the brand color in text inputs and textareas. A small detail that signals a polished design. ## References - [accent-color — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color) - [appearance — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/appearance) - [field-sizing — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing) - [caret-color — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/caret-color) - [Simplifying Form Styles with accent-color — web.dev](https://web.dev/articles/accent-color) - [Custom Checkbox Styling — Modern CSS Solutions](https://moderncss.dev/pure-css-custom-checkbox-style/) - [forced-colors Media Query — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors) --- # Scroll Snap > Source: https://takazudomodular.com/pj/zcss/docs/interactive/scroll/scroll-snap ## The Problem Carousels, slideshows, and horizontally scrolling content sections are extremely common UI patterns. AI agents almost always reach for JavaScript libraries or custom scroll event handlers to implement snap-to-slide behavior. CSS Scroll Snap provides this functionality natively with a few lines of CSS, delivering better performance and touch device compatibility than JavaScript solutions. AI rarely suggests it. ## The Solution CSS Scroll Snap lets you define snap points on a scroll container so that scrolling naturally locks to specific positions. The browser handles all the physics — momentum, deceleration, and snapping — resulting in smooth, native-feeling scroll behavior on all devices. ### Key Properties - **`scroll-snap-type`** (on the scroll container): Defines the snapping axis (`x`, `y`, or `both`) and strictness (`mandatory` or `proximity`). - **`scroll-snap-align`** (on child items): Defines where each item should snap (`start`, `center`, or `end`). - **`scroll-snap-stop`** (on child items): Controls whether scrolling can skip past items (`normal`) or must stop at each one (`always`). ### mandatory vs. proximity - **`mandatory`**: The scroll container **always** snaps to a snap point when scrolling stops. Even if the user scrolls only slightly, it snaps to the nearest point. Best for carousels and paginated content. - **`proximity`**: The scroll container only snaps when near a snap point. If the user scrolls past snap points, it behaves like normal scrolling. Best for long content where snapping is helpful but not required. 1 Slide One 2 Slide Two 3 Slide Three 4 Slide Four 5 Slide Five Scroll horizontally — slides snap into place `} css={` .carousel { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 1rem; padding: 1rem; } .carousel::-webkit-scrollbar { height: 6px; } .carousel::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; } .carousel::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } .slide { flex: 0 0 calc(80% - 0.5rem); scroll-snap-align: start; border-radius: 0.75rem; color: white; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 160px; gap: 0.5rem; } .slide-num { font-size: 2.5rem; font-weight: 800; opacity: 0.9; } .slide-label { font-size: 0.875rem; font-weight: 600; opacity: 0.8; } .hint { font-size: 0.75rem; color: #94a3b8; text-align: center; margin: 0.5rem 0 0; font-style: italic; } `} /> ## Code Examples ### Horizontal Carousel ```css .carousel { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; /* Hide scrollbar but keep functionality */ scrollbar-width: none; } .carousel::-webkit-scrollbar { display: none; } .carousel__slide { flex: 0 0 100%; scroll-snap-align: start; } ``` ```html Slide 1 Slide 2 Slide 3 ``` ### Multi-Item Carousel (Peek Next Item) ```css .carousel-peek { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 1rem; padding-inline: 1rem; } .carousel-peek__item { flex: 0 0 calc(80% - 0.5rem); scroll-snap-align: start; border-radius: 0.5rem; background: var(--color-surface, #f5f5f5); padding: 1.5rem; } ``` `scroll-padding-inline` on the container ensures the snapped item is offset from the edge, revealing a peek of the next item. ### Vertical Full-Page Sections ```css .page-sections { height: 100vh; overflow-y: auto; scroll-snap-type: y mandatory; } .section { height: 100vh; scroll-snap-align: start; display: flex; align-items: center; justify-content: center; } ``` ```html Section 1 Section 2 Section 3 ``` ### Image Gallery with Center Snapping ```css .gallery { display: flex; gap: 0.5rem; overflow-x: auto; scroll-snap-type: x proximity; padding-block: 1rem; } .gallery__image { flex: 0 0 auto; width: min(300px, 80vw); aspect-ratio: 3 / 4; object-fit: cover; border-radius: 0.5rem; scroll-snap-align: center; } ``` Using `proximity` here allows free-form browsing with gentle snap-to-center behavior. ### Preventing Fast-Scroll Skipping ```css .carousel-strict { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } .carousel-strict__slide { flex: 0 0 100%; scroll-snap-align: start; scroll-snap-stop: always; /* Must stop at every slide */ } ``` `scroll-snap-stop: always` prevents users from swiping past multiple slides at once. ### Responsive Carousel to Grid ```css .card-scroller { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 1rem; } .card-scroller__item { flex: 0 0 min(280px, 85vw); scroll-snap-align: start; } /* On wider screens, switch to a grid layout */ @media (min-width: 48rem) { .card-scroller { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); overflow-x: visible; scroll-snap-type: none; } .card-scroller__item { scroll-snap-align: unset; } } ``` ## Common AI Mistakes - **Not suggesting scroll snap at all**: Defaulting to JavaScript carousel libraries for scroll-snap behavior that CSS handles natively. - **Forgetting `scroll-snap-type` on the container**: Setting `scroll-snap-align` on children but not enabling snapping on the parent. - **Always using `mandatory`**: Using `mandatory` for long scrolling content where `proximity` is more appropriate. `mandatory` on tall content can trap users. - **Not using `scroll-padding`**: Forgetting to add `scroll-padding` when the page has a fixed header, causing snapped content to be hidden behind it. - **Hiding scrollbars without maintaining accessibility**: Removing scrollbars with CSS but not providing alternative navigation (arrows, dots). - **Not using `scroll-snap-stop`**: For step-by-step content (like onboarding flows), not using `scroll-snap-stop: always` to prevent skipping. - **Fixed-pixel widths for slide items**: Using `flex: 0 0 350px` instead of responsive sizing like `min(300px, 85vw)`. ## When to Use - **Carousels and slideshows**: Full-width image carousels, testimonial sliders, product showcases. - **Horizontal scrolling sections**: Card scrollers, category navigation, image galleries. - **Full-page section scrolling**: Landing pages with distinct sections that snap vertically. - **Onboarding flows**: Step-by-step screens where each step should snap into view. - **Not for complex carousel logic**: If you need autoplay, infinite looping, or API-driven slide management, you may still need JavaScript — but the scroll-snap behavior itself should remain CSS-driven. ## References - [scroll-snap-type — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scroll-snap-type) - [Basic Concepts of Scroll Snap — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Basic_concepts) - [Well-Controlled Scrolling with CSS Scroll Snap — web.dev](https://web.dev/articles/css-scroll-snap) - [Practical CSS Scroll Snapping — CSS-Tricks](https://css-tricks.com/practical-css-scroll-snapping/) - [CSS Scroll Snap — Ahmad Shadeed](https://ishadeed.com/article/css-scroll-snap/) --- # The :has() Selector > Source: https://takazudomodular.com/pj/zcss/docs/interactive/selectors/has-selector ## The Problem CSS has never had a way to select a parent element based on its children. Developers have relied on JavaScript to toggle classes for parent-child state relationships, such as highlighting a form group when its input is invalid, or changing a card layout based on whether it contains an image. AI agents almost never use `:has()` and instead suggest JavaScript-based solutions for these patterns. ## The Solution The `:has()` relational pseudo-class selects elements that contain at least one element matching the given selector list. It acts as a "parent selector" but is far more powerful: it can look at any relative position (children, siblings, descendants) to conditionally apply styles. ## Code Examples ### Basic Parent Selection ```css /* Style a card differently when it contains an image */ .card:has(img) { grid-template-rows: 200px 1fr; } .card:has(img) .card-body { padding-top: 0; } ``` ### Form Validation Styling Style form groups based on input validity without JavaScript. ```css /* Highlight the entire field group when input is invalid */ .field-group:has(:user-invalid) { border-left: 3px solid red; background: #fff5f5; } .field-group:has(:user-invalid) .error-message { display: block; } /* Style label when its sibling input is focused */ .field-group:has(input:focus) label { color: blue; font-weight: bold; } ``` ```html Email Please enter a valid email ``` ### Quantity Queries Adapt layout based on the number of children, with no JavaScript required. ```css /* Switch to grid layout when a list has 5 or more items */ .item-list:has(> :nth-child(5)) { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } /* Single-column layout for fewer items */ .item-list:not(:has(> :nth-child(5))) { display: flex; flex-direction: column; } ``` ```css /* Style based on even/odd number of children */ .grid:has(> :last-child:nth-child(even)) { /* Even number of children */ grid-template-columns: repeat(2, 1fr); } .grid:has(> :last-child:nth-child(odd)) { /* Odd number of children */ grid-template-columns: repeat(3, 1fr); } ``` ### Styling Based on Sibling State ```css /* Change page layout when a sidebar checkbox is checked */ body:has(#sidebar-toggle:checked) .main-content { margin-left: 0; } body:has(#sidebar-toggle:checked) .sidebar { transform: translateX(-100%); } ``` ### Combining with Other Selectors ```css /* Style a navigation item that contains the current page link */ nav li:has(> a[aria-current="page"]) { background: #e0e7ff; border-radius: 4px; } /* Style a table row that has an empty cell */ tr:has(td:empty) { opacity: 0.6; } ``` ### Using `:has()` with Direct Child Combinator Use the direct child combinator `>` for better performance. It limits the browser's search to immediate children instead of all descendants. ```css /* Preferred: direct child (faster) */ .container:has(> .alert) { border: 2px solid red; } /* Avoid when possible: descendant (slower on large DOMs) */ .container:has(.alert) { border: 2px solid red; } ``` ## Browser Support - Chrome 105+ - Safari 15.4+ - Firefox 121+ - Edge 105+ Global support exceeds 96%. Feature detection is available with `@supports selector(:has(*))`. ## Common AI Mistakes - Suggesting JavaScript class toggling when `:has()` solves the problem in pure CSS - Not knowing `:has()` exists and recommending workarounds - Using descendant selectors inside `:has()` when direct child `>` would be more performant - Not combining `:has()` with `:not()` for inverse logic (e.g., `.card:not(:has(img))`) - Forgetting that `:has()` can look at siblings, not just descendants (e.g., `h2:has(+ p)`) - Attempting to polyfill `:has()` — it requires real-time DOM awareness and cannot be efficiently polyfilled ## When to Use - Parent styling based on child state (form validation, content-aware layouts) - Quantity queries to adapt layout based on number of children - State-driven styling without JavaScript (checkbox hacks, focus management) - Conditional component styling based on content presence ## Live Previews Name Email Click an input — the parent field-group highlights via :has(:focus) `} css={` .field-group { font-family: system-ui, sans-serif; padding: 1rem; margin-bottom: 0.75rem; border: 2px solid #e5e7eb; border-radius: 8px; transition: border-color 0.2s, background 0.2s; } .field-group:has(:focus) { border-color: #3b82f6; background: #eff6ff; } .field-group:has(:focus) label { color: #2563eb; font-weight: 700; } label { display: block; font-size: 0.875rem; color: #64748b; margin-bottom: 0.5rem; transition: color 0.2s; } input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 1rem; outline: none; box-sizing: border-box; } input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 0.5rem; } `} /> Mark as featured Article Title This card changes appearance when the checkbox is checked — all done with :has(:checked) in pure CSS. Click the checkbox to see the card transform `} css={` .card { font-family: system-ui, sans-serif; padding: 1.5rem; border: 2px solid #e5e7eb; border-radius: 12px; background: #fff; transition: all 0.3s; } .card:has(:checked) { border-color: #f59e0b; background: linear-gradient(135deg, #fffbeb, #fef3c7); box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2); } .card:has(:checked) h3 { color: #b45309; } .toggle-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem; color: #64748b; margin-bottom: 1rem; } .toggle-label input { width: 1.1rem; height: 1.1rem; cursor: pointer; } h3 { margin: 0 0 0.5rem; color: #1e293b; transition: color 0.3s; } p { margin: 0; color: #64748b; line-height: 1.6; } .hint { font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 0.75rem; } `} /> ## References - [:has() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:has) - [:has() CSS relational pseudo-class - Can I Use](https://caniuse.com/css-has) - [CSS :has() Parent Selector - Ahmad Shadeed](https://ishadeed.com/article/css-has-parent-selector/) - [The CSS :has Selector - CSS-Tricks](https://css-tricks.com/the-css-has-selector/) - [Quantity Queries with CSS :has() - Frontend Masters](https://frontendmasters.com/blog/quantity-queries-are-very-easy-with-css-has/) --- # Hover, Focus, and Active States > Source: https://takazudomodular.com/pj/zcss/docs/interactive/states-and-transitions/hover-focus-active-states ## The Problem Interactive elements need visual feedback for hover, focus, and active states. AI agents commonly add `:hover` styles that create sticky hover states on touch devices, omit `:focus` and `:focus-visible` styling (breaking keyboard accessibility), and apply identical styles to all three states when they should be distinct. The result is an interface that works with a mouse but frustrates touch users and keyboard navigators. ## The Solution Style each interaction state with purpose, use `@media (hover: hover)` to scope hover effects to devices that support them, and use `:focus-visible` instead of `:focus` for keyboard-only focus indicators. ### The Three States - **`:hover`** — A pointing device is over the element (mouse, trackpad). Does not apply reliably on touch devices. - **`:focus`** — The element is focused, whether by mouse click, keyboard tab, or programmatic focus. - **`:focus-visible`** — The element is focused **and** the browser determines a visible indicator is appropriate (typically keyboard navigation). Mouse clicks on buttons do not trigger `:focus-visible`. - **`:active`** — The element is being activated (mouse button held down, finger pressing on touch). Primary Button Outline Button Link Style Hover over buttons to see hover state. Press Tab to see focus-visible ring. Click and hold to see active state. `} css={` .demo { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; padding: 1.5rem; } .hint { width: 100%; font-size: 0.75rem; color: #94a3b8; margin: 0.5rem 0 0; font-style: italic; } .btn { padding: 0.625rem 1.25rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: 2px solid transparent; text-decoration: none; display: inline-flex; align-items: center; transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; } .btn-primary { background-color: #3b82f6; color: white; } .btn-primary:hover { background-color: #2563eb; } .btn-primary:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .btn-primary:active { transform: scale(0.96); background-color: #1d4ed8; } .btn-outline { background: transparent; color: #3b82f6; border-color: #3b82f6; } .btn-outline:hover { background: #eff6ff; } .btn-outline:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .btn-outline:active { transform: scale(0.96); background: #dbeafe; } .btn-link { background: none; color: #3b82f6; padding: 0.625rem 0.25rem; text-decoration: underline; text-underline-offset: 0.2em; } .btn-link:hover { color: #1d4ed8; text-decoration-thickness: 2px; } .btn-link:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; border-radius: 2px; } .btn-link:active { color: #1e40af; } `} /> ## Code Examples ### Basic Button States ```css .button { background-color: var(--color-primary, #2563eb); color: white; border: 2px solid transparent; padding: 0.625rem 1.25rem; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease; } /* Hover: only on devices that support it */ @media (hover: hover) { .button:hover { background-color: var(--color-primary-dark, #1d4ed8); } } /* Focus-visible: keyboard focus indicator */ .button:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } /* Active: pressed state */ .button:active { transform: scale(0.97); } ``` ### Link States (LVHA Order) Link pseudo-classes should follow the LVHA order to avoid specificity conflicts: ```css a:link { color: var(--color-link, #2563eb); text-decoration: underline; text-underline-offset: 0.2em; } a:visited { color: var(--color-link-visited, #7c3aed); } @media (hover: hover) { a:hover { color: var(--color-link-hover, #1d4ed8); text-decoration-thickness: 2px; } } a:focus-visible { outline: 2px solid var(--color-link, #2563eb); outline-offset: 2px; border-radius: 2px; } a:active { color: var(--color-link-active, #1e40af); } ``` ### Card with Hover Effect (Touch-Safe) ```css .card { background: var(--color-surface, #ffffff); border-radius: 0.5rem; border: 1px solid var(--color-border, #e5e7eb); padding: 1.5rem; transition: box-shadow 0.2s ease, transform 0.2s ease; } /* Only apply hover elevation on hover-capable devices */ @media (hover: hover) { .card:hover { box-shadow: 0 4px 16px rgb(0 0 0 / 0.1); transform: translateY(-2px); } } /* Keyboard focus */ .card:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } ``` ### Focus-Visible vs. Focus ```css /* Remove default focus ring for mouse users */ .interactive:focus { outline: none; } /* Show focus ring only for keyboard users */ .interactive:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } ``` A safer approach that does not rely on removing `:focus` styles entirely: ```css /* Visible focus ring for keyboard navigation */ .interactive:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } /* Subtle focus style for mouse clicks (if desired) */ .interactive:focus:not(:focus-visible) { outline: none; } ``` ### Touch vs. Mouse Input Detection ```css /* Base interactive styles */ .nav-link { padding: 0.5rem 1rem; color: var(--color-text); text-decoration: none; } /* Hover effects only for precise pointers */ @media (hover: hover) and (pointer: fine) { .nav-link:hover { background-color: var(--color-surface-hover, #f3f4f6); } } /* Larger touch targets for coarse pointers */ @media (pointer: coarse) { .nav-link { min-height: 44px; display: flex; align-items: center; padding: 0.75rem 1rem; } } ``` ### Form Input Focus States ```css .input { border: 1px solid var(--color-border, #d1d5db); border-radius: 0.375rem; padding: 0.5rem 0.75rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; } /* All focus (mouse and keyboard) gets a border change */ .input:focus { border-color: var(--color-primary, #2563eb); box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15); outline: none; } ``` For form inputs, using `:focus` (not `:focus-visible`) is usually correct because users need to see which input they are typing into, regardless of how they focused it. ## Common AI Mistakes - **Adding `:hover` without `@media (hover: hover)`**: Hover effects persist as "sticky" on touch devices after a tap, confusing users. - **Removing `:focus` outlines without replacement**: Writing `outline: none` on `:focus` without providing any visible focus indicator, making the page inaccessible to keyboard users. - **Using `:focus` instead of `:focus-visible`**: Showing a focus ring on every mouse click (buttons, cards) when only keyboard focus needs a visible indicator. - **Applying identical styles to all states**: Making `:hover`, `:focus`, and `:active` look the same, which removes meaningful visual feedback about the interaction type. - **Forgetting the LVHA order**: Writing `:hover` before `:visited`, causing specificity conflicts in link styling. - **Not testing on touch devices**: Assuming hover works everywhere and never verifying the interaction on mobile. - **Using `cursor: pointer` on everything**: Adding `cursor: pointer` to non-interactive elements like divs, which misleads users. ## When to Use - **`:hover` with `@media (hover: hover)`**: Visual enhancements (color shifts, shadows, elevation) that only make sense with a mouse or trackpad. - **`:focus-visible`**: Keyboard focus indicators on buttons, links, and custom interactive elements. - **`:focus`**: Form inputs where all focus types need a visible indicator. - **`:active`**: Pressed/tapped feedback (scale, color change) for buttons and interactive elements. - **`@media (pointer: coarse)`**: Increasing touch target sizes and padding for touch devices. ## Tailwind CSS Tailwind provides `hover:`, `focus:`, `focus-visible:`, and `active:` variant prefixes that map directly to CSS pseudo-classes. Primary Button Outline Button Link Style Hover over buttons, tab to see focus ring, click and hold for active state. `} height={130} /> ## References - [:focus-visible — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) - [Solving Sticky Hover States with @media (hover: hover) — CSS-Tricks](https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/) - [Style hover, focus, and active states differently — Zell Liew](https://zellwk.com/blog/style-hover-focus-active-states/) - [Focus or Focus-Visible? A Guide to Accessible Focus States — Maya Shavin](https://mayashavin.com/articles/focus-vs-focus-visible-for-accessibility) --- # Flexbox Patterns > Source: https://takazudomodular.com/pj/zcss/docs/layout/flexbox-and-grid/flexbox-patterns ## The Problem Flexbox is the most commonly used CSS layout model, yet AI agents frequently misapply it. Common mistakes include using flexbox when CSS Grid is more appropriate, forgetting to set `min-width: 0` to prevent overflow, using fixed heights instead of flex-grow for sticky footers, and defaulting to `justify-content` and `align-items` without understanding the flex axis. ## The Solution Flexbox is a one-dimensional layout model. It excels at distributing space along a single axis (row or column). Use it for component-level layouts, navigation bars, toolbars, and any scenario where items flow in one direction. ## Code Examples ### Centering (Horizontal and Vertical) ```css .centered-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; } ``` ```html Perfectly centered ``` Perfectly centered `} css={`.centered-container { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f1f5f9; border-radius: 8px; } .content { background: #3b82f6; color: #fff; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-family: system-ui, sans-serif; }`} height={220} /> ### Equal-Height Columns Flex items in a row container stretch to the same height by default via `align-items: stretch`. ```css .columns { display: flex; gap: 1rem; } .column { flex: 1; /* No need for align-items or explicit height */ } ``` ```html Short content Much longer content that determines the height of all columns. All siblings will match this height automatically. Medium content here ``` Short Much longer content that determines the height of all columns. All siblings match this height automatically. Medium content here `} css={`.columns { display: flex; gap: 12px; padding: 12px; font-family: system-ui, sans-serif; } .column { flex: 1; background: #8b5cf6; color: #fff; padding: 16px; border-radius: 8px; font-size: 16px; } .column p { margin: 0 0 8px 0; }`} /> ### Sticky Footer The footer sticks to the bottom of the viewport when content is short, and flows naturally below content when it is tall. ```css body { display: flex; flex-direction: column; min-height: 100vh; margin: 0; } main { flex: 1; } /* header and footer need no special styles */ ``` ```html Header Main content Footer ``` Header Main content (short) Footer sticks to bottom `} css={`.page { display: flex; flex-direction: column; min-height: 300px; font-family: system-ui, sans-serif; font-size: 16px; } .header { background: #3b82f6; color: #fff; padding: 12px 20px; } .main { flex: 1; padding: 20px; background: #f1f5f9; } .footer { background: #22c55e; color: #fff; padding: 12px 20px; }`} height={320} /> ### Space-Between with Wrapping When items wrap, `justify-content: space-between` can leave awkward gaps on the last row. Use `gap` instead for consistent spacing. ```css /* Problematic: last row items spread apart */ .bad-wrap { display: flex; flex-wrap: wrap; justify-content: space-between; } /* Better: consistent gaps between items */ .good-wrap { display: flex; flex-wrap: wrap; gap: 1rem; } .good-wrap > * { flex: 0 1 calc(33.333% - 1rem); } ``` space-between (broken last row): 1 2 3 4 5 gap (consistent spacing): 1 2 3 4 5 `} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #334155; } .bad-wrap { display: flex; flex-wrap: wrap; justify-content: space-between; margin-bottom: 20px; } .good-wrap { display: flex; flex-wrap: wrap; gap: 12px; } .item { width: 30%; background: #ef4444; color: #fff; padding: 12px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; margin-bottom: 12px; } .item.good { background: #22c55e; width: calc(33.333% - 12px); margin-bottom: 0; }`} /> ### Preventing Overflow with min-width: 0 Flex items have `min-width: auto` by default, which prevents them from shrinking below their content size. This causes text overflow in constrained layouts. ```css .card { display: flex; gap: 1rem; } .card-content { flex: 1; min-width: 0; /* Allow content to shrink and enable text-overflow */ } .card-content h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ``` ### Navigation Bar ```css .navbar { display: flex; align-items: center; gap: 1rem; } .navbar-logo { margin-right: auto; /* Pushes nav items to the right */ } ``` ```html Logo About Contact ``` ## Common AI Mistakes - **Using flexbox for two-dimensional layouts.** If items need to align in both rows and columns simultaneously, CSS Grid is the correct choice. Flexbox only controls one axis. - **Using `height: 100vh` instead of `min-height: 100vh` for sticky footer.** A fixed height causes content overflow on long pages. - **Forgetting `min-width: 0` on flex items.** Without it, flex items refuse to shrink below their content width, causing horizontal overflow. - **Using `margin` instead of `gap` for spacing.** Margin on flex items creates double spacing where items meet and requires workarounds for first/last child. `gap` applies only between items. - **Using `flex-wrap: wrap` with `justify-content: space-between`.** This creates unpredictable gaps on the last row. Use `gap` with calculated flex-basis instead. - **Writing `flex: 1 1 0` when `flex: 1` suffices.** The shorthand `flex: 1` already sets `flex-grow: 1; flex-shrink: 1; flex-basis: 0%`. - **Nesting flex containers unnecessarily.** If a simple `margin-right: auto` or `gap` can achieve the spacing, avoid wrapping items in extra containers. ## When to Use ### Flexbox is ideal for - Single-axis layouts (a row of buttons, a navigation bar, a toolbar) - Distributing space among items of unknown or varying size - Vertically centering content within a container - Component-level layout (card internals, form rows, media objects) - Sticky footers using `flex-direction: column` ### Use CSS Grid instead when - You need items to align in both rows and columns (a grid of cards) - You have a complex page-level layout with named areas - You want auto-placement of items into a responsive grid - You need items to span multiple rows or columns ## Tailwind CSS Tailwind provides utility classes for all flexbox properties. Here are the key patterns from this article expressed with Tailwind classes. ### Centering Perfectly centered `} height={220} /> ### Equal-Height Columns Short Much longer content that determines the height of all columns. All siblings match this height automatically. Medium content here `} /> ### Sticky Footer Header Main content (short) Footer sticks to bottom `} height={320} /> ## References - [A Complete Guide to Flexbox - CSS-Tricks](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) - [Flexbox - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox) - [Solved by Flexbox](https://philipwalton.github.io/solved-by-flexbox/) - [Flexbox Layout - web.dev](https://web.dev/learn/css/flexbox) --- # Centering Techniques > Source: https://takazudomodular.com/pj/zcss/docs/layout/positioning/centering-techniques ## The Problem Centering elements is one of the most fundamental CSS tasks, yet AI agents frequently produce overcomplicated or inappropriate solutions. Common mistakes include using `text-align: center` on non-inline elements, applying `transform: translate(-50%, -50%)` when flexbox or grid would suffice, stacking multiple centering techniques on top of each other, and reaching for `position: absolute` when the layout does not require it. ## The Solution Modern CSS provides clean, purpose-built centering techniques. The right choice depends on the context: what you are centering, which axes you need, and whether the parent has a defined height. ## Code Examples ### Horizontal Centering with margin: auto For block-level elements with a defined width, `margin: auto` is the simplest horizontal centering technique. It does not center vertically. ```css .centered-block { width: 600px; /* or max-width */ margin-inline: auto; } ``` ```html Horizontally centered block element. ``` `margin-inline: auto` is the logical-property equivalent of `margin-left: auto; margin-right: auto` and works correctly in all writing directions. ### Horizontal Centering of Inline/Text Content ```css .text-center { text-align: center; } ``` This centers inline content (text, ``, ``, inline-block elements) within a block container. It does not center block-level children. ### Flexbox Centering (Both Axes) ```css .flex-center { display: flex; justify-content: center; /* horizontal */ align-items: center; /* vertical */ } ``` ```html Centered both ways ``` The parent needs a defined height (or `min-height`) for vertical centering to be visible. ### Grid Centering (Both Axes, Most Concise) ```css .grid-center { display: grid; place-items: center; } ``` ```html Centered with one line ``` `place-items: center` is shorthand for `align-items: center; justify-items: center`. This is the most concise centering technique in CSS. ### Grid Centering with place-content An alternative for single-child centering: ```css .grid-center-alt { display: grid; place-content: center; min-height: 100vh; } ``` The difference: `place-items` aligns items within their grid area, while `place-content` aligns the grid tracks themselves. For single-child centering, both produce the same result. ### Centering with margin: auto inside Flex or Grid ```css .container { display: flex; /* or display: grid */ min-height: 100vh; } .child { margin: auto; } ``` When a flex or grid item has `margin: auto`, it absorbs all available space on that axis, centering the item both horizontally and vertically. ### Absolute Positioning with Inset For overlay content that needs to be centered over a positioned parent: ```css .overlay-parent { position: relative; } .overlay-centered { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; } ``` ### Transform Technique (Legacy) ```css .transform-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } ``` This technique is still valid but is rarely the best choice in modern CSS. It can cause blurry text on non-retina displays due to sub-pixel rendering. Prefer flexbox or grid. ## Live Previews Centered with Flexbox `} css={` .flex-center { display: flex; justify-content: center; align-items: center; height: 200px; background: #f0f4f8; } .box { padding: 16px 24px; background: #3b82f6; color: white; border-radius: 8px; font-family: system-ui, sans-serif; font-weight: 600; } `} /> Centered with Grid `} css={` .grid-center { display: grid; place-items: center; height: 200px; background: #f0fdf4; } .box { padding: 16px 24px; background: #22c55e; color: white; border-radius: 8px; font-family: system-ui, sans-serif; font-weight: 600; } `} /> ## Quick Reference | Scenario | Technique | |---|---| | Block element, horizontal only | `margin-inline: auto` (requires width) | | Inline/text content, horizontal only | `text-align: center` on parent | | Single child, both axes | `display: grid; place-items: center` | | Multiple children, both axes | `display: flex; justify-content: center; align-items: center` | | Child inside flex/grid, both axes | `margin: auto` on child | | Overlay on positioned parent | `position: absolute; inset: 0; margin: auto` | | Legacy / pre-flexbox codebases | `transform: translate(-50%, -50%)` | ## Common AI Mistakes - **Using `transform: translate(-50%, -50%)` as the default.** This technique is a legacy fallback. Modern CSS has cleaner options with flexbox and grid. - **Forgetting to set height on the parent.** Vertical centering requires the parent to have a defined height or min-height. Without it, the parent collapses to the content height and centering appears to do nothing. - **Using `text-align: center` on block elements.** `text-align` only affects inline content. It does not center a `` inside another ``. - **Stacking multiple centering techniques.** Applying both `margin: auto` and `justify-content: center` is redundant. Pick one approach. - **Using `position: absolute` when the element is part of the flow.** Absolute positioning removes the element from the document flow, which is usually undesirable for centering content within a layout. - **Writing `margin: 0 auto` instead of `margin-inline: auto`.** While both work in left-to-right languages, the physical property version resets vertical margins to 0, which may override intended spacing. `margin-inline: auto` only affects the horizontal axis. ## When to Use ### Grid `place-items: center` Best for centering a single child element. The most concise syntax. Use when you do not need the parent to also be a flex container for other layout purposes. ### Flexbox centering Best when the parent is already a flex container, or when you need to center multiple items along one axis and distribute them along another. ### margin-inline: auto Best for horizontally centering a block element with a known width or max-width. Does not require changing the parent's display type. ### Absolute positioning Only for overlays, modals, tooltips, and elements that must be removed from the document flow and positioned over other content. ## Tailwind CSS Tailwind provides concise utility classes for all centering techniques. ### Flexbox Centering Centered with Flexbox `} height={220} /> ### Grid Centering Centered with Grid `} height={220} /> ### Horizontal Centering with mx-auto Centered with mx-auto + w-fit `} height={100} /> ## References - [Centering in CSS - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/How_to/Layout_cookbook/Center_an_element) - [The Complete Guide to Centering in CSS - Modern CSS Solutions](https://moderncss.dev/complete-guide-to-centering-in-css/) - [Centering Things - CSS-Tricks](https://css-tricks.com/centering-css-complete-guide/) - [Learn CSS Box Alignment - web.dev](https://web.dev/learn/css/box-alignment) --- # fit-content, max-content, min-content > Source: https://takazudomodular.com/pj/zcss/docs/layout/sizing/fit-content ## The Problem Block-level elements in CSS default to `width: auto`, which stretches them to fill their container. When you need an element to shrink to fit its content — for example, a tag, a tooltip, or a call-to-action button styled as a block — AI agents typically reach for `width: auto` (which does nothing since it is the default) or `width: 100%` (which is the opposite of what is needed). The intrinsic sizing keywords `fit-content`, `max-content`, and `min-content` solve this directly, but AI agents rarely use them. ## The Solution CSS provides three intrinsic sizing keywords that let elements size themselves based on their content: - **`fit-content`** — the element shrinks to its content but never exceeds its container width. This is the most commonly needed behavior. - **`max-content`** — the element expands to fit all its content on a single line, even if that overflows the container. - **`min-content`** — the element shrinks to the narrowest possible width without causing overflow of its smallest unbreakable content (e.g., the longest word). ## Code Examples ### Shrink a Block Element to Its Content ```css /* The element is as wide as its content, up to the container width */ .tag { width: fit-content; padding: 0.25rem 0.75rem; background: #e0e7ff; border-radius: 9999px; } ``` ```html New Feature A longer tag label that still shrinks to fit ``` Without `width: fit-content`, each `.tag` would stretch to fill its parent. With it, each tag is only as wide as its text plus padding. ### Centering a Shrink-to-Fit Element A common pattern is centering a block element that should be as narrow as its content: ```css .centered-tag { width: fit-content; margin-inline: auto; } ``` This is cleaner than switching to `display: inline-block` and wrapping in a `text-align: center` parent. ### Comparing the Three Keywords ```css .min { width: min-content; /* Shrinks to the longest word. Text wraps aggressively. */ } .max { width: max-content; /* Expands to fit all content on one line. May overflow container. */ } .fit { width: fit-content; /* Like max-content, but capped at the container width. */ } ``` ```html This text wraps at every opportunity This text stays on one line even if it overflows the container This text stays on one line if it fits, otherwise wraps at 300px ``` ### Using min-content for Fixed-Width Columns `min-content` is useful in grid layouts when you want a column to be exactly as wide as its narrowest content: ```css .table-layout { display: grid; grid-template-columns: min-content 1fr min-content; gap: 1rem; } ``` ```html Status Description of the item that can be long Actions ``` The first and last columns shrink to their content; the middle column takes the remaining space. ### fit-content() Function in Grid The `fit-content()` function (with parentheses) is specifically for grid track sizing. It accepts a maximum length argument: ```css .sidebar-layout { display: grid; grid-template-columns: fit-content(300px) 1fr; gap: 2rem; } ``` The sidebar column shrinks to its content but never exceeds 300px. This is different from `minmax(auto, 300px)` because `fit-content()` will not grow beyond the content width even if space is available. ### Practical Example: Notification Badge ```css .badge { display: block; width: fit-content; padding: 0.125rem 0.5rem; font-size: 0.75rem; background: #ef4444; color: white; border-radius: 9999px; } ``` ```html 3 99+ ``` Each badge is exactly as wide as its content. No fixed width, no overflow. ## Live Preview width: 100% (default block) New Feature Badge width: fit-content New Feature Badge `} css={` .demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .section { flex: 1; } .section h4 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #334155; } .tag { padding: 6px 16px; background: #e0e7ff; border-radius: 9999px; font-size: 14px; margin-bottom: 8px; } .full { width: 100%; } .fit { width: fit-content; } `} /> ## Common AI Mistakes - **Using `width: auto` when `width: fit-content` is needed.** `width: auto` on a block element means "stretch to fill the container," which is the opposite of shrink-to-fit. AI agents often default to `auto` thinking it means "automatic sizing." - **Using `display: inline-block` as a workaround for shrink-to-fit.** While `inline-block` does cause shrink-to-fit behavior, it changes the element's formatting context and can cause alignment issues. `width: fit-content` achieves the same sizing without changing the display type. - **Confusing `fit-content` (keyword) with `fit-content()` (function).** The keyword works anywhere `width`, `height`, `min-width`, etc. accept a value. The function is only valid in grid track sizing (`grid-template-columns`, `grid-template-rows`). - **Using `max-content` when `fit-content` is intended.** `max-content` can cause overflow because it ignores the container width. `fit-content` is almost always the safer choice. - **Setting `width: 100%` on elements that should shrink.** AI agents frequently apply `width: 100%` to buttons, tags, and badges, forcing them to fill the container when they should size to their content. - **Forgetting that `min-content` wraps text aggressively.** It breaks at every soft wrap opportunity, which can produce very narrow, hard-to-read elements. It is mainly useful for grid track sizing, not for general element widths. ## When to Use ### fit-content - Block elements that should shrink to their content: tags, badges, tooltips, captions - Centering a content-width element with `margin-inline: auto` - Any scenario where you want "as narrow as the content, but no wider than the container" ### max-content - When you need to know or enforce the natural single-line width of content - Calculating intrinsic sizes for animations or measurements - Rarely used for final layout because it can overflow ### min-content - Grid columns that should be as narrow as possible (icon columns, status labels) - Understanding the minimum space an element needs before it overflows - Rarely used as a standalone `width` value because it wraps text aggressively ### fit-content() function - Grid track sizing where you want a column to shrink to content but cap at a maximum width - Sidebar layouts: `grid-template-columns: fit-content(250px) 1fr` ## Tailwind CSS Tailwind provides `w-fit`, `w-min`, and `w-max` utilities for intrinsic sizing. ### w-fit vs Default Width Default (stretches) New Feature Badge w-fit (shrinks) New Feature Badge `} height={140} /> ### Centered Fit-Content Element Centered Badge `} height={80} /> ## References - [fit-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/fit-content) - [max-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/max-content) - [min-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/min-content) - [fit-content() - CSS-Tricks](https://css-tricks.com/almanac/functions/f/fit-content/) - [Understanding min-content, max-content, and fit-content in CSS - LogRocket Blog](https://blog.logrocket.com/understanding-min-content-max-content-fit-content-css/) --- # Object Fit and Object Position > Source: https://takazudomodular.com/pj/zcss/docs/layout/specialized/object-fit-and-position ## The Problem When images or videos are placed inside fixed-dimension containers, they get stretched or distorted because replaced elements default to `object-fit: fill`. Developers often work around this by switching to `background-image` on a ``, which sacrifices semantics, accessibility (`alt` text), native lazy loading, and SEO discoverability. The correct solution for `` and `` elements is `object-fit`, which controls how the content fills its box without leaving HTML behind. ## The Solution `object-fit` controls how a replaced element's content is resized to fit its container. It works the same way `background-size` works for background images, but on actual ``, ``, and other replaced elements. `object-position` then controls the alignment of the content within the element's box, letting you pick the focal point. ### Core Principles #### object-fit Values - **`fill`** (default) — Stretches the content to fill the box exactly. Ignores aspect ratio. Almost never what you want for photos. - **`contain`** — Scales the content to fit entirely inside the box while preserving aspect ratio. May leave empty space (letterboxing). - **`cover`** — Scales the content to cover the entire box while preserving aspect ratio. Parts of the image may be clipped. - **`none`** — No resizing at all. The content is displayed at its intrinsic size. Overflows are clipped. - **`scale-down`** — Acts as `none` or `contain`, whichever produces a smaller result. Prevents upscaling. #### object-position Works exactly like `background-position`. It accepts keyword values (`top`, `center`, `right`), percentages, or length values. The default is `50% 50%` (centered). This is how you control the focal point when `object-fit: cover` clips the image. #### Relationship with aspect-ratio The `aspect-ratio` property defines the box's proportions, while `object-fit` controls how the content fills that box. They work together: set `aspect-ratio` on the element to define the container shape, then use `object-fit` to control how the image content adapts to that shape. ```css img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } ``` This gives you a responsive image that always maintains a 16:9 frame while the photo inside fills it edge-to-edge. ## Live Previews fill (default) contain cover none scale-down `} css={` .fit-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; padding: 16px; font-family: system-ui, sans-serif; } .fit-item { display: flex; flex-direction: column; gap: 8px; } .fit-label { font-size: 12px; font-weight: 600; color: hsl(220 15% 40%); text-align: center; padding: 4px 8px; background: hsl(220 20% 95%); border-radius: 4px; } .fit-item img { width: 100%; height: 160px; border: 2px solid hsl(220 20% 90%); border-radius: 6px; background: hsl(220 20% 97%); } .fit-fill { object-fit: fill; } .fit-contain { object-fit: contain; } .fit-cover { object-fit: cover; } .fit-none { object-fit: none; } .fit-scale-down { object-fit: scale-down; } `} /> Anna K. Marco R. Sara L. James T. Yuki N. `} css={` .avatar-grid { display: flex; gap: 20px; padding: 24px; justify-content: center; font-family: system-ui, sans-serif; } .avatar-card { display: flex; flex-direction: column; align-items: center; gap: 8px; } .avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; border: 3px solid hsl(220 20% 90%); } .avatar-name { font-size: 13px; font-weight: 500; color: hsl(220 15% 35%); } `} /> left top center (default) right bottom 25% 75% `} css={` .position-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .position-item { display: flex; flex-direction: column; gap: 6px; } .position-label { font-size: 12px; font-weight: 600; color: hsl(220 15% 40%); padding: 4px 8px; background: hsl(220 20% 95%); border-radius: 4px; text-align: center; } .position-item img { width: 100%; height: 140px; object-fit: cover; border: 2px solid hsl(220 20% 90%); border-radius: 6px; } .pos-left-top { object-position: left top; } .pos-center { object-position: center; } .pos-right-bottom { object-position: right bottom; } .pos-custom { object-position: 25% 75%; } `} /> Getting Started with CSS Grid Learn the fundamentals of CSS Grid layout and build responsive layouts with ease. Flexbox Deep Dive Master one-dimensional layouts with flexbox patterns for real-world components. Modern Color Systems Explore oklch, color-mix, and relative color syntax for design-system-ready palettes. `} css={` .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .card { border-radius: 10px; overflow: hidden; border: 1px solid hsl(220 20% 90%); background: white; } .card-image { width: 100%; height: 160px; object-fit: cover; display: block; } .card-body { padding: 12px 14px; } .card-title { font-size: 14px; font-weight: 700; color: hsl(220 25% 20%); margin: 0 0 6px; line-height: 1.3; } .card-text { font-size: 12px; color: hsl(220 15% 50%); margin: 0; line-height: 1.5; } `} /> object-fit (recommended) <img> with object-fit: cover Semantic HTML — it's an image Built-in alt text for accessibility Native lazy loading with loading="lazy" Indexed by search engines Works with <picture> and srcset background-image (avoid) <div> with background-image Non-semantic — it's a div, not an image No alt text — invisible to screen readers No native lazy loading Not indexed as an image by search engines Cannot use <picture> or srcset `} css={` .comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .comparison-column { display: flex; flex-direction: column; gap: 8px; } .comparison-header { font-size: 13px; font-weight: 700; text-align: center; padding: 6px 12px; border-radius: 6px; } .comparison-header.good { background: hsl(150 50% 92%); color: hsl(150 60% 30%); } .comparison-header.bad { background: hsl(0 50% 94%); color: hsl(0 60% 40%); } .comparison-demo { height: 120px; border-radius: 8px; overflow: hidden; border: 2px solid hsl(220 20% 90%); } .comparison-img { width: 100%; height: 100%; object-fit: cover; display: block; } .comparison-bg { width: 100%; height: 100%; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='500' height='300' viewBox='0 0 500 300'%3E%3Cdefs%3E%3ClinearGradient id='p' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='hsl(200,80%25,55%25)'/%3E%3Cstop offset='100%25' stop-color='hsl(240,70%25,50%25)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='500' height='300' fill='url(%23p)'/%3E%3Ccircle cx='250' cy='130' r='60' fill='hsl(45,90%25,60%25)' opacity='0.9'/%3E%3Crect x='100' y='220' width='300' height='16' rx='8' fill='hsl(0,0%25,100%25)' opacity='0.3'/%3E%3Crect x='140' y='250' width='220' height='16' rx='8' fill='hsl(0,0%25,100%25)' opacity='0.3'/%3E%3C/svg%3E"); background-size: cover; background-position: center; } .comparison-code { font-size: 12px; color: hsl(220 15% 45%); text-align: center; } .comparison-code code { background: hsl(220 20% 95%); padding: 2px 5px; border-radius: 3px; font-size: 11px; } .comparison-list { list-style: none; padding: 0; margin: 0; font-size: 11px; display: flex; flex-direction: column; gap: 3px; } .comparison-list li { padding: 3px 6px; border-radius: 4px; line-height: 1.4; } .pro { background: hsl(150 45% 94%); color: hsl(150 50% 28%); } .con { background: hsl(0 45% 96%); color: hsl(0 50% 38%); } .pro::before { content: "✓ "; font-weight: 700; } .con::before { content: "✗ "; font-weight: 700; } `} /> ## Quick Reference | Scenario | CSS | |---|---| | Image fills container, preserving ratio, clipping edges | `object-fit: cover` | | Image fits inside container with letterboxing | `object-fit: contain` | | Image at intrinsic size, no resizing | `object-fit: none` | | Prevent upscaling beyond intrinsic size | `object-fit: scale-down` | | Control focal point when cover clips | `object-position: top` or `object-position: 25% 75%` | | Responsive 16:9 frame with cover | `aspect-ratio: 16/9; object-fit: cover` | | Circular avatar from any aspect ratio | `border-radius: 50%; object-fit: cover` | ## Common AI Mistakes - **Using `background-image` instead of `object-fit`.** When the content is a meaningful image (not decorative), use `` with `object-fit`. Reserve `background-image` for purely decorative backgrounds. - **Forgetting to set explicit dimensions on the image.** `object-fit` only has a visible effect when the element's box size differs from the content's intrinsic size. Set `width` and `height` (or use `aspect-ratio`) on the ``. - **Not setting `display: block` on images inside cards.** Inline images have a small gap below them due to baseline alignment. Add `display: block` to remove it. - **Using `object-fit: fill` intentionally.** `fill` is the default and stretches the image. If you explicitly set `object-fit: fill`, you are distorting the image. Use `cover` or `contain` instead. - **Ignoring `object-position` when using `cover`.** The default centering may crop the wrong part of the image. Use `object-position` to control which part stays visible. - **Not combining `aspect-ratio` with `object-fit`.** Setting only `width: 100%` without `aspect-ratio` or a fixed `height` means the image box follows its intrinsic ratio, making `object-fit` unnecessary. ## References - [object-fit — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) - [object-position — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) - [Replaced Elements — CSS-Tricks](https://css-tricks.com/almanac/properties/o/object-fit/) - [aspect-ratio — web.dev](https://web.dev/articles/aspect-ratio) --- # BEM Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/architecture/bem-strategy :::note[Historical Context] BEM is a **traditional CSS naming convention** from an era before CSS scoping was widely available. It was invented to simulate the "scope" that other programming languages take for granted — preventing class name collisions in plain, global CSS. **Most modern projects don't need BEM.** Today's tooling handles scoping automatically: - **CSS Modules** — class names are locally scoped at build time - **Vue / Svelte scoped styles** — `` generates unique selectors per component - **Tailwind CSS** — utility-first approach removes the need for naming conventions entirely - **CSS-in-JS** (styled-components, Emotion) — styles are component-scoped by default BEM remains valuable as **foundational knowledge** — it explains _why_ modern scoping solutions were invented and how they think about component boundaries. It's also still relevant when modern tooling isn't available. Think of BEM as understanding the "why" behind the tools you use today, not as a pattern to reach for in new projects. ::: ## The Problem CSS naming without convention leads to naming collisions, specificity wars, and unclear relationships between styles. In multi-developer projects, different team members invent different naming patterns, creating inconsistency. AI agents often generate arbitrary class names like `.title`, `.container`, or `.btn-blue` — names that inevitably collide across components. Worse, they default to deeply nested selectors like `.sidebar .nav ul li a.active` that create specificity chains impossible to override without `!important`. ## The Solution BEM (Block Element Modifier) provides a strict naming convention: `.block__element--modifier`. This creates flat, single-class selectors that avoid specificity issues entirely and communicate component structure through the names themselves. - **Block**: A standalone, reusable component (`.card`, `.nav`, `.form`) - **Element**: A part of a block, prefixed with the block name and double underscore (`.card__title`, `.card__body`) - **Modifier**: A variation of a block or element, suffixed with double hyphen (`.card--featured`, `.card__title--large`) Every selector has the same specificity (one class), so the cascade becomes predictable and overrides are straightforward. ## Code Examples ### The BEM Convention ``` .block → standalone component .block__element → child part of the block .block--modifier → variation of the block .block__element--modifier → variation of an element ``` Examples: ```css /* Block */ .card { } .nav { } .form { } /* Element */ .card__title { } .card__body { } .card__image { } /* Modifier */ .card--featured { } .card__title--large { } ``` ### Card Component A complete card component using BEM naming. The featured modifier changes the card's appearance while maintaining the same structure. Standard Card This is a regular card using BEM naming. Each class clearly shows its role within the component. Read more Featured Card This card uses the --featured modifier. The modifier class is added alongside the base block class. Read more `} css={`* { box-sizing: border-box; margin: 0; } .card-grid { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .card { flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #fff; } .card--featured { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); } .card__image { width: 100%; height: 120px; object-fit: cover; display: block; } .card__body { padding: 16px; } .card__title { font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 8px; } .card__text { font-size: 14px; color: #64748b; line-height: 1.5; margin-bottom: 12px; } .card__link { font-size: 14px; color: #3b82f6; text-decoration: none; font-weight: 500; } .card__link:hover { text-decoration: underline; } .card__link--primary { background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 4px; } .card__link--primary:hover { background: #2563eb; text-decoration: none; }`} height={340} /> ### Navigation Component A nav component where the active state is expressed as a modifier, not a separate class like `.active`. Home About Services Contact `} css={`* { box-sizing: border-box; margin: 0; } .nav { display: flex; gap: 4px; background: #1e293b; padding: 8px; border-radius: 8px; font-family: system-ui, sans-serif; } .nav__item { padding: 10px 20px; color: #94a3b8; text-decoration: none; font-size: 14px; font-weight: 500; border-radius: 6px; transition: background 0.2s, color 0.2s; } .nav__item:hover { background: #334155; color: #f1f5f9; } .nav__item--active { background: #3b82f6; color: #fff; } .nav__item--active:hover { background: #2563eb; }`} height={60} /> ### Form Component A form using BEM with an error state modifier on the input. The error styling is scoped to the element through naming, not through parent selectors. Email Password Required field Submit `} css={`* { box-sizing: border-box; margin: 0; } .form { max-width: 400px; padding: 24px; font-family: system-ui, sans-serif; } .form__group { margin-bottom: 16px; } .form__label { display: block; font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 4px; } .form__input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; outline: none; transition: border-color 0.2s; } .form__input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } .form__input--error { border-color: #ef4444; } .form__input--error:focus { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); } .form__error { display: block; font-size: 12px; color: #ef4444; margin-top: 4px; } .form__submit { background: #3b82f6; color: #fff; border: none; padding: 10px 24px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .form__submit:hover { background: #2563eb; }`} height={260} /> ## Common Mistakes ### Nesting elements too deep BEM elements should not reflect DOM nesting. Flatten element names to reference only the block. ```css /* Wrong: mirrors the DOM tree */ .card__body__title__text { } /* Correct: flat reference to the block */ .card__text { } ``` ### Using a modifier without the base class A modifier class should always be paired with the base block or element class. The modifier only overrides specific properties — the base class provides the foundation. ```html ... ... ``` ### Using BEM for layout BEM is for naming component classes, not for page-level layout. Layout concerns should use separate utility or layout classes. ```html ... ... ``` ## BEM with Modern CSS CSS nesting (now supported in all major browsers) makes BEM even more ergonomic. The `&` selector lets you write all rules inside the block, keeping the naming convention while reducing repetition. ```css .card { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; &__image { width: 100%; display: block; } &__title { font-size: 18px; font-weight: 600; } &__body { padding: 16px; } &--featured { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); } } ``` ℹ️ Information This is a standard alert message. ⚠️ Warning Something needs your attention. ✅ Success Operation completed successfully. `} css={`* { box-sizing: border-box; margin: 0; } .alert { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-left: 4px solid #3b82f6; background: #eff6ff; border-radius: 0 6px 6px 0; margin: 8px 16px; font-family: system-ui, sans-serif; &__icon { font-size: 18px; flex-shrink: 0; line-height: 1.4; } &__content { flex: 1; } &__title { font-size: 14px; font-weight: 600; color: #1e293b; display: block; margin-bottom: 2px; } &__text { font-size: 13px; color: #475569; line-height: 1.4; } &--warning { border-left-color: #f59e0b; background: #fffbeb; } &--success { border-left-color: #22c55e; background: #f0fdf4; } }`} height={230} /> ## When to Use **Most modern projects don't need BEM.** If you're starting a new project with a frontend framework, you almost certainly have better scoping options available. ### What replaced BEM - **CSS Modules** → local scope by default, no naming convention needed - **Vue / Svelte scoped styles** → `` handles collision prevention automatically - **Tailwind CSS** → no class naming at all, utilities compose directly - **CSS-in-JS** → component-level scope baked in ### When BEM is still relevant - **No build tools** — plain HTML/CSS projects where you're writing global stylesheets - **Legacy codebases** — incrementally refactoring toward better architecture - **Multi-team without framework agreement** — a shared naming convention prevents collisions when teams can't agree on tooling - **Server-rendered apps** — HTML and CSS ship separately without a build step to scope styles ### BEM as foundational knowledge Even if you never write BEM in production, understanding it is worthwhile. It explains _why_ CSS Modules and scoped styles exist, and what problem they're solving. The concepts — flat selectors, single-class specificity, component-scoped naming — carry over into how modern tools think about styles. ## References - [BEM — Block Element Modifier](https://getbem.com/) - [BEM Methodology — Quick Start](https://en.bem.info/methodology/quick-start/) - [MindBEMding — Getting your head round BEM syntax - CSS Wizardry](https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/) --- # Custom Properties Pattern Catalog > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/custom-properties-advanced/pattern-catalog A comprehensive collection of CSS custom property patterns for building robust design systems, responsive layouts, and component architectures — all with interactive demos. ## Responsive Custom Properties Custom properties that change at breakpoints using `@media` create a responsive design system without utility classes. Define your tokens once and let media queries adapt them. ```css :root { --content-padding: 1rem; } @media (min-width: 768px) { :root { --content-padding: 2rem; } } ``` Every element referencing `--content-padding` automatically updates at the breakpoint — no per-component overrides needed. Responsive Container This container's padding, gap, and font size all adapt via custom properties at breakpoints. Resize the viewport toggle to see them change. Card A Card B Card C `} css={` :root { --content-padding: 1rem; --content-gap: 0.75rem; --content-font: 0.875rem; } @media (min-width: 768px) { :root { --content-padding: 2rem; --content-gap: 1.5rem; --content-font: 1rem; } } .page { font-family: system-ui, sans-serif; } .container { background: hsl(220 20% 97%); border: 1px solid hsl(220 15% 88%); border-radius: 12px; padding: var(--content-padding); font-size: var(--content-font); } .container h2 { margin: 0 0 0.5rem; font-size: 1.15em; color: hsl(220 30% 30%); } .container p { margin: 0 0 1rem; color: hsl(220 15% 50%); line-height: 1.5; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--content-gap); } .card { background: white; border: 1px solid hsl(220 15% 88%); border-radius: 8px; padding: var(--content-padding); text-align: center; font-weight: 600; color: hsl(220 30% 40%); } `} /> ## CSS Counters with Custom Properties CSS counters generate automatic numbering. Combine them with custom properties to make the counter styling configurable — change colors, sizes, or shapes from a parent element. Define your base custom properties on the root element Create fallback chains for component-level overrides Use scoped properties for variant styling Add calc() for computed relationships between values `} css={` .counter-demo { font-family: system-ui, sans-serif; --counter-bg: hsl(250 80% 60%); --counter-color: white; --counter-size: 2rem; } .steps { list-style: none; padding: 0; margin: 0; counter-reset: step-counter; } .step { counter-increment: step-counter; display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid hsl(250 20% 92%); color: hsl(250 20% 30%); line-height: 1.5; } .step:last-child { border-bottom: none; } .step::before { content: counter(step-counter); display: flex; align-items: center; justify-content: center; min-width: var(--counter-size); height: var(--counter-size); background: var(--counter-bg); color: var(--counter-color); border-radius: 50%; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; } `} /> ## Custom Property Inheritance for Component Trees A parent component sets custom properties, and deeply nested children inherit them automatically — no prop drilling, no extra classes. This is one of the most powerful patterns for component theming. Purple Theme The badge below inherits from the card. Inherited Color No Prop Drilling Teal Theme Same card structure, different accent. Cascade Power Zero JS `} css={` .card-list { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .themed-card { --_accent: var(--card-accent, hsl(220 70% 55%)); background: white; border: 2px solid var(--_accent); border-radius: 12px; padding: 1.25rem; } .themed-card-title { margin: 0 0 0.5rem; font-size: 1rem; color: var(--_accent); } .themed-card-body { margin: 0 0 1rem; font-size: 0.85rem; color: hsl(220 15% 45%); line-height: 1.5; } .themed-card-footer { display: flex; gap: 0.5rem; flex-wrap: wrap; } /* Tags inherit --_accent from the card ancestor */ .tag { background: var(--_accent); color: white; padding: 0.2rem 0.65rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; } `} /> ## Calc-Based Spacing Scale Define a single base spacing unit, then derive an entire scale with `calc()`. Changing the base value reshapes every spacing token at once. ```css :root { --space-unit: 8px; --space-xs: calc(var(--space-unit) * 0.5); --space-sm: var(--space-unit); --space-md: calc(var(--space-unit) * 2); --space-lg: calc(var(--space-unit) * 3); --space-xl: calc(var(--space-unit) * 5); } ``` Spacing Scale (base: 8px) xs (4px) sm (8px) md (16px) lg (24px) xl (40px) Card using the scale Padding: md, gap: sm Same scale, same rhythm Consistent spacing everywhere `} css={` :root { --space-unit: 8px; --space-xs: calc(var(--space-unit) * 0.5); --space-sm: var(--space-unit); --space-md: calc(var(--space-unit) * 2); --space-lg: calc(var(--space-unit) * 3); --space-xl: calc(var(--space-unit) * 5); } .spacing-demo { font-family: system-ui, sans-serif; } .spacing-title { margin: 0 0 1rem; font-size: 1rem; color: hsl(220 30% 30%); } .scale-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .scale-label { font-size: 0.8rem; color: hsl(220 15% 50%); min-width: 5.5rem; text-align: right; font-variant-numeric: tabular-nums; } .scale-bar { height: 1.25rem; background: hsl(220 80% 60%); border-radius: 4px; } .scale-xs { width: var(--space-xs); } .scale-sm { width: var(--space-sm); } .scale-md { width: var(--space-md); } .scale-lg { width: var(--space-lg); } .scale-xl { width: var(--space-xl); } .card-demo { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-top: var(--space-lg); } .space-card { background: hsl(220 30% 96%); border: 1px solid hsl(220 20% 88%); border-radius: 8px; padding: var(--space-md); } .space-card h4 { margin: 0 0 var(--space-xs); font-size: 0.9rem; color: hsl(220 30% 30%); } .space-card p { margin: 0; font-size: 0.8rem; color: hsl(220 15% 55%); } `} /> ## Color System with Custom Properties Separate HSL components into individual custom properties for maximum flexibility. You can derive hover states, lighter/darker variants, and transparency from a single color definition. ```css :root { --primary-h: 220; --primary-s: 80%; --primary-l: 50%; } .button { background: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); } .button:hover { background: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 10%)); } ``` Primary Secondary Accent Hover each button — the darkened hover state is computed from the same HSL base using calc() `} css={` :root { --primary-h: 220; --primary-s: 80%; --primary-l: 50%; --secondary-h: 160; --secondary-s: 60%; --secondary-l: 42%; --accent-h: 340; --accent-s: 75%; --accent-l: 55%; } .color-demo { font-family: system-ui, sans-serif; } .button-row { display: flex; gap: 0.75rem; flex-wrap: wrap; } .btn { border: none; padding: 0.65rem 1.5rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; color: white; cursor: pointer; transition: background 0.15s; } .btn-primary { background: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); } .btn-primary:hover { background: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 10%)); } .btn-secondary { background: hsl(var(--secondary-h) var(--secondary-s) var(--secondary-l)); } .btn-secondary:hover { background: hsl(var(--secondary-h) var(--secondary-s) calc(var(--secondary-l) - 10%)); } .btn-accent { background: hsl(var(--accent-h) var(--accent-s) var(--accent-l)); } .btn-accent:hover { background: hsl(var(--accent-h) var(--accent-s) calc(var(--accent-l) - 10%)); } .color-hint { font-size: 0.8rem; color: hsl(220 15% 55%); margin-top: 1rem; } `} /> ## References - [Using CSS custom properties (variables) - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascading_variables/Using_custom_properties) - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Custom Properties as State - Chris Coyier](https://css-tricks.com/custom-properties-as-state/) --- # Color Token Patterns > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/tight-token-strategy/color-tokens ## The Problem Tailwind CSS ships with approximately 22 color families, each with 11 shades (50 through 950), resulting in 240+ color utilities. In practice, teams end up using `blue-500` in one component, `blue-600` in another, and `indigo-500` in a third — all intended to mean "primary button blue." Without constraints, every shade is equally valid, so inconsistency grows silently. The same drift happens with grays. One developer uses `gray-100` for a card background, another picks `slate-50`, and a third reaches for `zinc-200`. All are "light backgrounds," but none match. Over time the UI develops a patchwork of subtly different tones that undermines visual coherence. ## The Solution Reset all default colors and define a small set of semantic color tokens organized by purpose. After the reset, `bg-blue-500` and `text-gray-700` no longer work — the team is forced to use the project's intentional color vocabulary. ### Token Categories The categories below follow a semantic layering approach — raw palette values are replaced by role-based tokens that components reference. This is the same principle as the [Three-Tier Color Strategy](../../../styling/color/three-tier-color-strategy) (palette → theme → component), applied specifically to Tailwind's `@theme` system. Organize colors into five groups: 1. **Brand colors** — `primary`, `secondary`, `accent`, each with `light`, `base`, and `dark` variants 2. **Semantic/state colors** — `success`, `warning`, `error`, `info` for feedback and status 3. **Surface colors** — `surface`, `surface-alt`, `surface-inverse` for backgrounds 4. **Text colors** — `text`, `text-muted`, `text-inverse` for readable hierarchy 5. **Border colors** — `border`, `border-focus` for edges and focus rings ### The @theme Color Block If you use [Approach B](../#approach-b-skip-the-default-theme-recommended) (separate imports without the default theme), no reset line is needed. If you use Approach A (`@import "tailwindcss"`), add `--color-*: initial;` at the top. ```css @theme { /* If using Approach A, uncomment: --color-*: initial; */ /* ── Brand ── */ --color-primary-light: hsl(217 91% 60%); --color-primary: hsl(221 83% 53%); --color-primary-dark: hsl(224 76% 48%); --color-secondary-light: hsl(250 80% 68%); --color-secondary: hsl(252 78% 60%); --color-secondary-dark: hsl(255 70% 52%); --color-accent-light: hsl(38 95% 64%); --color-accent: hsl(33 95% 54%); --color-accent-dark: hsl(28 90% 46%); /* ── State ── */ --color-success: hsl(142 71% 45%); --color-warning: hsl(38 92% 50%); --color-error: hsl(0 84% 60%); --color-info: hsl(199 89% 48%); /* ── Surface ── */ --color-surface: hsl(0 0% 100%); --color-surface-alt: hsl(210 40% 96%); --color-surface-inverse: hsl(222 47% 11%); /* ── Text ── */ --color-text: hsl(222 47% 11%); --color-text-muted: hsl(215 16% 47%); --color-text-inverse: hsl(210 40% 98%); /* ── Border ── */ --color-border: hsl(214 32% 91%); --color-border-focus: hsl(221 83% 53%); } ``` After this configuration, Tailwind utilities like `bg-surface`, `text-primary`, and `border-border-focus` are the only color options available. Reaching for `bg-gray-100` causes a build error. ## Demos ### Default Grays vs Semantic Surface Tokens The left side shows a sampling of Tailwind's 22 default gray shades — all technically valid for backgrounds. The right side shows the 3 semantic surface tokens that replace them. Fewer choices means faster decisions and guaranteed consistency. Default grays (sample) slate-50 slate-100 slate-200 slate-300 gray-700 gray-500 zinc-500 zinc-900 neutral-100 neutral-400 stone-300 stone-900 12 of 240+ color utilities shown. Which is "card background"? Semantic surfaces surfacehsl(0 0% 100%) surface-althsl(210 40% 96%) surface-inversehsl(222 47% 11%) 3 surface tokens. "Card background" is always surface. `} css={`.demo { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; color: hsl(222 47% 11%); } .col { flex: 1; } .heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 10px; } .swatch-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; } .swatch-grid.tight { grid-template-columns: 1fr; gap: 6px; } .swatch { border: 1px solid hsl(214 32% 91%); border-radius: 6px; padding: 8px 6px; font-size: 10px; text-align: center; display: flex; flex-direction: column; gap: 2px; } .swatch.lg { padding: 14px 12px; font-size: 13px; flex-direction: row; justify-content: space-between; align-items: center; } .swatch span { font-weight: 600; } .swatch em { font-style: normal; font-size: 11px; opacity: 0.7; } .note { margin-top: 8px; font-size: 11px; color: hsl(215 16% 47%); line-height: 1.4; } .note code { background: hsl(210 40% 96%); padding: 1px 4px; border-radius: 3px; font-size: 10px; }`} /> ### Button Set with Semantic Color Tokens These buttons use only the semantic color tokens — `primary`, `secondary`, `error`, and `accent`. No numeric color shades are involved. Every button in the project uses these same tokens, so the palette stays consistent. Primary Action Secondary Delete Upgrade Primary Outline Secondary Outline Delete Outline Upgrade Outline bg-primary bg-secondary bg-error bg-accent `} css={`.btn-demo { padding: 20px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; background: hsl(0 0% 100%); } .btn-row { display: flex; gap: 10px; flex-wrap: wrap; } .btn { padding: 8px 18px; border: 2px solid transparent; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; color: hsl(210 40% 98%); } /* ── Solid variants ── */ .btn-primary { background: hsl(221 83% 53%); } .btn-secondary { background: hsl(252 78% 60%); } .btn-danger { background: hsl(0 84% 60%); } .btn-accent { background: hsl(33 95% 54%); color: hsl(222 47% 11%); } /* ── Outline variants ── */ .btn.outline { background: transparent; } .btn-primary.outline { border-color: hsl(221 83% 53%); color: hsl(221 83% 53%); } .btn-secondary.outline { border-color: hsl(252 78% 60%); color: hsl(252 78% 60%); } .btn-danger.outline { border-color: hsl(0 84% 60%); color: hsl(0 84% 60%); } .btn-accent.outline { border-color: hsl(33 95% 54%); color: hsl(28 90% 46%); } .token-labels { display: flex; gap: 10px; flex-wrap: wrap; } .token-labels span { font-size: 10px; font-family: monospace; background: hsl(210 40% 96%); padding: 2px 8px; border-radius: 4px; color: hsl(215 16% 47%); }`} /> ### Card with Surface, Text, and Border Tokens This card demonstrates all five token categories working together. The background uses `surface` and `surface-alt`, text uses `text` and `text-muted`, borders use `border`, and the badge uses `primary` brand color. Every color in the component maps to exactly one semantic token. Project Dashboard Active This card uses semantic color tokens for every color value. No numeric shades like gray-200 or blue-500 appear anywhere. Status Healthy Errors 3 issues Updated 2 hours ago View Details Dismiss surface → card bg surface-alt → header, footer bg text → headings, body text-muted → labels, meta border → card border, dividers primary → badge, action button `} css={`.card-demo { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; background: hsl(210 40% 96%); color: hsl(222 47% 11%); } /* ── Card ── */ .card { flex: 1; background: hsl(0 0% 100%); /* surface */ border: 1px solid hsl(214 32% 91%); /* border */ border-radius: 8px; overflow: hidden; } .card-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: hsl(210 40% 96%); /* surface-alt */ border-bottom: 1px solid hsl(214 32% 91%); /* border */ } .card-title { font-weight: 700; font-size: 15px; color: hsl(222 47% 11%); /* text */ } .badge { font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: hsl(221 83% 53%); /* primary */ color: hsl(210 40% 98%); /* text-inverse */ } .card-body { padding: 16px; } .card-text { color: hsl(222 47% 11%); /* text */ line-height: 1.6; margin: 0 0 14px 0; } .card-text code { background: hsl(210 40% 96%); padding: 1px 5px; border-radius: 3px; font-size: 12px; color: hsl(0 84% 60%); /* error — for code highlighting */ } .meta-row { display: flex; gap: 20px; } .meta-label { font-size: 11px; color: hsl(215 16% 47%); /* text-muted */ margin-bottom: 2px; } .meta-value { font-weight: 600; font-size: 13px; } .meta-value.success { color: hsl(142 71% 45%); } /* success */ .meta-value.error { color: hsl(0 84% 60%); } /* error */ .card-footer { display: flex; gap: 8px; padding: 12px 16px; background: hsl(210 40% 96%); /* surface-alt */ border-top: 1px solid hsl(214 32% 91%); /* border */ } .btn-view { padding: 6px 14px; border: none; border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer; background: hsl(221 83% 53%); /* primary */ color: hsl(210 40% 98%); /* text-inverse */ } .btn-dismiss { padding: 6px 14px; border: 1px solid hsl(214 32% 91%); /* border */ border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer; background: transparent; color: hsl(215 16% 47%); /* text-muted */ } /* ── Token map ── */ .token-map { width: 200px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: hsl(215 16% 47%); padding-top: 4px; } .tm-row { display: flex; align-items: center; gap: 8px; } .tm-swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid hsl(214 32% 91%); flex-shrink: 0; }`} /> ## Palette Growth Naming When a project starts with a tight token set, each color family typically has only one value. Name it plainly — `gray`, not `gray1`. If the project later needs a second shade in that family, add `gray2`. A third becomes `gray3`. ```css @theme { /* ── Initial palette ── */ --color-gray: hsl(25 5% 45%); /* ── Added later when a dark card surface was needed ── */ --color-gray2: hsl(0 3% 13%); } ``` This "no-number-for-first" rule keeps initial token names clean and avoids a renaming cascade when the palette grows: - `gray` → the original gray, used since day one - `gray2` → added later for dark backgrounds - `gray3` → added even later for a muted border Compare with numbering from 1 (`gray1`, `gray2`, `gray3`) — it forces the first token to carry a meaningless suffix, and every existing reference needs updating if you decide to retroactively insert `gray1`. The pattern applies to every color family: `primary` / `primary2`, `surface` / `surface2`, `accent` / `accent2`, and so on. ### Real-World Example The zmod project uses exactly this pattern: ```css --zd-color-gray: rgb(120, 113, 108); /* Original gray */ --zd-color-gray2: #201f1f; /* Added later for dark backgrounds */ ``` No renaming was needed when `gray2` arrived — the original `gray` stayed untouched across the entire codebase. ### Before and After: Growing a Palette This demo shows a simple card UI that initially uses a single `gray` token. When the design later requires a darker card surface, `gray2` is added without touching the original `gray`. Phase 1 — One gray gray Settings Theme Default Language English Last saved 2 min ago gray handles all muted text. One token, clean name. → Phase 2 — Add gray2 gray gray2 Settings Theme Default Language English Last saved 2 min ago gray2 added for the dark header. Original gray unchanged — no renaming needed. `} css={`.growth-demo { display: flex; align-items: flex-start; gap: 12px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; color: hsl(222 47% 11%); background: hsl(210 40% 96%); } .growth-col { flex: 1; display: flex; flex-direction: column; gap: 8px; } .growth-arrow { font-size: 24px; font-weight: 700; color: hsl(215 16% 47%); padding-top: 90px; flex-shrink: 0; } .growth-heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .growth-tokens { display: flex; gap: 10px; flex-wrap: wrap; } .growth-token { display: flex; align-items: center; gap: 5px; font-size: 12px; } .growth-token code { background: hsl(0 0% 100%); padding: 1px 6px; border-radius: 3px; font-size: 11px; } .growth-swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid hsl(214 32% 91%); } .growth-card { border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; background: hsl(0 0% 100%); } .growth-card__header { padding: 10px 14px; font-weight: 700; font-size: 14px; background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); color: hsl(222 47% 11%); } .growth-card__header--dark { background: hsl(0 3% 13%); /* gray2 */ color: hsl(210 40% 98%); border-bottom: none; } .growth-card__body { padding: 12px 14px; display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; } .growth-card__label { font-size: 12px; color: hsl(25 5% 45%); /* gray */ font-weight: 500; } .growth-card__value { font-size: 12px; font-weight: 600; } .growth-card__footer { padding: 8px 14px; border-top: 1px solid hsl(214 32% 91%); } .growth-card__hint { font-size: 11px; color: hsl(25 5% 45%); /* gray */ } .growth-note { font-size: 11px; color: hsl(215 16% 47%); line-height: 1.4; } .growth-note code { background: hsl(0 0% 100%); padding: 1px 4px; border-radius: 3px; font-size: 10px; }`} /> ### Why Not Number from 1? Starting with `gray1` seems symmetrical, but it creates problems: - **Visual noise on the most common token** — The vast majority of references use the first color. `gray1` adds a meaningless digit everywhere. - **Renaming pressure** — If you start with `gray` and later need to "organize," you might feel compelled to rename it to `gray1` for consistency. That touches every file. The no-number-for-first rule removes that pressure entirely. - **Signals intent** — `gray2` clearly communicates "this is the second gray, added alongside the original." With `gray1` / `gray2`, both look like they were planned from the start. ## When to Use This color token strategy works best when combined with the [spacing token strategy](./index.mdx) from the parent article. Together, they constrain the two most common sources of visual drift — spacing and color — into a small, intentional design vocabulary. Apply color tokens when: - The project has more than one developer making color choices - The design system specifies named colors (e.g., "primary," "surface") rather than hex values - The brand has strict color guidelines that must be enforced consistently ## References - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # Tight Token Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/tight-token-strategy ## The Problem Tailwind CSS ships with an enormous default token set. The spacing scale alone includes values like `0`, `0.5`, `1`, `1.5`, `2`, `2.5`, `3`, `3.5`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12`, `14`, `16`, `20`, `24`, `28`, `32`, `36`, `40`, `44`, `48`, `52`, `56`, `60`, `64`, `72`, `80`, `96` — that is over 30 numeric steps for spacing. Multiply this by colors, font sizes, border radii, and other categories, and the available utility space becomes massive. In practice, this means any developer on the team can reach for any value at any time. One person writes `p-4`, another uses `p-5`, and a third picks `p-6` — all for "medium padding." There is no wrong answer because every value is valid, but the result is an inconsistent, drifting UI. Design reviews turn into debates about which numeric step is "correct," and refactoring spacing later requires auditing hundreds of utility classes scattered across the codebase. The root cause is that Tailwind's default tokens are **generic numeric scales**, not **semantic design decisions**. They tell you _how much_ but not _why_. ## The Solution Replace **all** Tailwind defaults with a small, intentional set of semantic tokens. Tailwind CSS v4's `@theme` directive makes this possible by allowing you to reset every built-in token with wildcard patterns and then define only the tokens your project actually needs. The strategy has two key ideas: 1. **Reset everything** — Use `--spacing-*: initial;`, `--color-*: initial;`, and similar wildcards to remove all default values. After this, utilities like `p-4` or `bg-gray-500` no longer exist. Attempting to use them causes a build error, which is exactly the point: invalid tokens are caught at build time, not in code review. 2. **Define semantic axes** — Instead of a single numeric spacing scale, define separate scales for different purposes. A production approach is to split spacing into two axes: - **hsp** (horizontal spacing): for inline gaps, horizontal padding, and horizontal margins - **vsp** (vertical spacing): for vertical gaps between sections, vertical padding, and block-level margins Each axis gets a limited scale from `2xs` to `2xl`, giving the team exactly 7 choices per axis. Combined with `0` and `1px` utility values, this is the entire spacing vocabulary of the project. ### Token Table **Horizontal spacing (hsp)**: | Token | Value | Usage | | --- | --- | --- | | `hsp-2xs` | 5px | Tight inline spacing | | `hsp-xs` | 12px | Small padding | | `hsp-sm` | 20px | Default horizontal padding | | `hsp-md` | 40px | Medium sections | | `hsp-lg` | 60px | Large sections | | `hsp-xl` | 100px | Extra large spacing | | `hsp-2xl` | 250px | Hero / feature spacing | **Vertical spacing (vsp)**: | Token | Value | Usage | | --- | --- | --- | | `vsp-2xs` | 4px | Minimal gaps | | `vsp-xs` | 8px | Tight component gaps | | `vsp-sm` | 20px | Default vertical gaps | | `vsp-md` | 35px | Section gaps | | `vsp-lg` | 50px | Large section gaps | | `vsp-xl` | 65px | Page section gaps | | `vsp-2xl` | 80px | Hero / major section gaps | ## Code Examples ### Two Approaches to Resetting Defaults There are two ways to achieve the tight token strategy in Tailwind CSS v4. Both produce the same result — only your project tokens exist — but they differ in mechanism. #### Approach A: Reset with `--*: initial` (explicit reset) Import everything, then reset what you don't want inside `@theme`: ```css @import "tailwindcss"; @theme { /* Reset ALL Tailwind defaults */ --spacing-*: initial; --color-*: initial; --font-size-*: initial; --font-family-*: initial; --font-weight-*: initial; --line-height-*: initial; --letter-spacing-*: initial; --border-radius-*: initial; --shadow-*: initial; --inset-shadow-*: initial; --drop-shadow-*: initial; --breakpoint-*: initial; /* Then define your tokens... */ } ``` This works, but the generated CSS still contains some Tailwind internal plumbing variables that were part of the default theme layer. #### Approach B: Skip the default theme (recommended) Import only the layers you need — preflight (reset CSS) and utilities — skipping the default theme entirely: ```css @import "tailwindcss/preflight"; @import "tailwindcss/utilities"; @theme { /* No resets needed — default theme was never loaded */ /* Define your tokens directly... */ } ``` `@import "tailwindcss"` is equivalent to importing three layers: `tailwindcss/preflight` (browser reset), `tailwindcss/theme` (all default tokens), and `tailwindcss/utilities` (utility class engine). By importing only preflight + utilities, the default theme is simply never loaded. **Approach B is recommended** because: - Fewer lines — no `--*: initial` reset block needed - Slightly smaller CSS output (~1KB less) — Tailwind's internal variables are not emitted - Cleaner mental model — you start from zero instead of resetting to zero #### What Approach B drops (vs Approach A) When you skip `tailwindcss/theme`, the following Tailwind internals are no longer emitted in the generated CSS: | Variable | Purpose | Do you need it? | | --- | --- | --- | | `--default-transition-duration` | Default duration for `transition` utilities | Add `--default-transition-duration: 0.15s;` in `@theme` if you use `transition` classes | | `--default-transition-timing-function` | Default easing for `transition` utilities | Add `--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);` in `@theme` if needed | | `--default-font-family` | Fallback for `font-family` on `html` | Not needed if you define `--font-sans` or set `font-family` yourself | | `--default-mono-font-family` | Fallback for `code`/`pre` font | Not needed if you define `--font-mono` | | `--animate-spin` | The `animate-spin` keyframe definition | Add manually if you use `animate-spin` | | `--ease-in-out` | Named easing curve | Add manually if you reference it | | `--container-*` | Container query widths | Not needed unless you use `@container` size utilities | In practice, if you use `transition-colors` or similar transition utilities, add these two lines to your `@theme`: ```css @theme { --default-transition-duration: 0.15s; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* ...your tokens... */ } ``` ### Full Example (Approach B) Place this in your project's main CSS file (e.g., `app.css`): ```css @import "tailwindcss/preflight"; @import "tailwindcss/utilities"; @theme { /* ======================================== * Define ONLY project tokens — Spacing * ======================================== */ --spacing-0: 0; --spacing-1px: 1px; /* Horizontal spacing */ --spacing-hsp-2xs: 5px; --spacing-hsp-xs: 12px; --spacing-hsp-sm: 20px; --spacing-hsp-md: 40px; --spacing-hsp-lg: 60px; --spacing-hsp-xl: 100px; --spacing-hsp-2xl: 250px; /* Vertical spacing */ --spacing-vsp-2xs: 4px; --spacing-vsp-xs: 8px; --spacing-vsp-sm: 20px; --spacing-vsp-md: 35px; --spacing-vsp-lg: 50px; --spacing-vsp-xl: 65px; --spacing-vsp-2xl: 80px; } ``` After this configuration: - `p-4` — **build error** (no `--spacing-4` token exists) - `bg-gray-500` — **build error** (no `--color-gray-500` token exists) - `px-hsp-sm` — **works** (resolves to `padding-inline: 20px`) - `py-vsp-md` — **works** (resolves to `padding-block: 35px`) ### Usage in Components With the tight token set, Tailwind classes become self-documenting. You can read the intent directly from the class name: ```html Page Title Introductory paragraph with standard vertical spacing below. Card A Card B ``` Every spacing value communicates its axis (horizontal vs vertical) and its relative size within the scale. ### Demo: Semantic Tokens vs Arbitrary Values The following demo shows the same card layout built two ways. The left card uses the tight semantic token approach; the right card uses arbitrary numeric spacing values, simulating how inconsistency creeps in when any value is allowed. Semantic tokens Article Title Body text with consistent spacing defined by design tokens. Tag A Tag B Arbitrary values Article Title Body text where each developer picked different padding values. Tag A Tag B `} css={`/* shared */ .demo-container { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 14px; } .demo-column { flex: 1; } .label { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 8px; } /* ── Semantic token card ── */ .card-semantic { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } .card-header-semantic { padding: 8px 20px; /* vsp-xs / hsp-sm */ font-weight: 700; font-size: 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; } .card-body-semantic { padding: 20px 20px; /* vsp-sm / hsp-sm */ color: #334155; line-height: 1.6; } .card-footer-semantic { padding: 8px 20px; /* vsp-xs / hsp-sm */ display: flex; gap: 12px; /* hsp-xs */ border-top: 1px solid #e2e8f0; background: #f8fafc; } .tag-semantic { background: #3b82f6; color: #fff; padding: 4px 12px; /* vsp-2xs / hsp-xs */ border-radius: 4px; font-size: 12px; } /* ── Arbitrary value card ── */ .card-arbitrary { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } .card-header-arbitrary { padding: 10px 16px; /* dev A picked 10/16 */ font-weight: 700; font-size: 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; } .card-body-arbitrary { padding: 14px 24px; /* dev B picked 14/24 */ color: #334155; line-height: 1.6; } .card-footer-arbitrary { padding: 12px 18px; /* dev C picked 12/18 */ display: flex; gap: 8px; border-top: 1px solid #e2e8f0; background: #f8fafc; } .tag-arbitrary-a { background: #3b82f6; color: #fff; padding: 6px 10px; /* dev A */ border-radius: 4px; font-size: 12px; } .tag-arbitrary-b { background: #3b82f6; color: #fff; padding: 3px 14px; /* dev B */ border-radius: 4px; font-size: 12px; }`} height={320} /> In the "Semantic tokens" card, every section uses values from the token table: `vsp-xs` (8px) for header/footer vertical padding, `hsp-sm` (20px) for horizontal padding, `vsp-sm` (20px) for body vertical padding, `hsp-xs` (12px) for tag horizontal padding. The result is a visually consistent card with a clear rhythm. In the "Arbitrary values" card, three different developers each picked slightly different padding values — `10px`, `14px`, `12px` vertically and `16px`, `24px`, `18px` horizontally. Tags have mismatched internal padding. The overall card looks subtly off-balance. This drift compounds across dozens of components in a real project. ### Demo: Page Layout with Semantic Spacing AppName Docs Blog About Welcome This layout uses semantic spacing tokens throughout. Every value is intentional and comes from the project's tight token set. Consistency Every component uses the same spacing vocabulary. Readability Class names tell you the intent, not just the number. Guardrails Invalid tokens cause build errors, not visual bugs. `} css={`.page { font-family: system-ui, sans-serif; font-size: 14px; color: #1e293b; } /* ── Header ── */ .page-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 20px; /* vsp-xs / hsp-sm */ background: #1e293b; color: #fff; } .logo { font-weight: 700; font-size: 16px; } .nav { display: flex; gap: 12px; /* hsp-xs */ } .nav a { color: #94a3b8; text-decoration: none; font-size: 13px; } .nav a:hover { color: #fff; } /* ── Main ── */ .page-main { padding: 35px 20px; /* vsp-md / hsp-sm */ } .page-title { font-size: 24px; font-weight: 700; margin: 0 0 8px 0; /* pb: vsp-xs */ } .page-intro { color: #475569; line-height: 1.6; margin: 0 0 35px 0; /* pb: vsp-md */ max-width: 600px; } /* ── Card grid ── */ .card-grid { display: flex; gap: 20px; /* hsp-sm */ flex-wrap: wrap; } .feature-card { flex: 1; min-width: 160px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px 20px; /* vsp-sm / hsp-sm */ } .feature-title { font-weight: 700; font-size: 15px; margin-bottom: 4px; /* vsp-2xs */ } .feature-desc { color: #64748b; line-height: 1.5; font-size: 13px; }`} height={340} /> Every spacing value in this layout maps directly to a token from the table. The header uses `vsp-xs` / `hsp-sm`, the main section uses `vsp-md` / `hsp-sm`, and the card grid uses `hsp-sm` for gaps. Reading the code (or the Tailwind classes in a real project) immediately tells you _which semantic slot_ each spacing value fills. ## When to Use ### Good fit - **Large teams** where multiple developers touch the same components — the constrained token set prevents spacing drift - **Design-system-driven projects** where designers hand off spacing specs using named tokens rather than pixel values - **Production applications** where visual consistency directly impacts user trust and brand perception - **Long-lived codebases** that will be maintained and refactored over years — a tight token set makes global spacing changes straightforward (update one token, the whole app adjusts) ### Not needed - **Prototypes and hackathons** where speed matters more than consistency - **Small personal projects** where one developer maintains full context - **Tailwind learning projects** where using the full default scale is part of the learning process ## Deep Dive For detailed token strategies in specific categories: - [Color Token Patterns](./color-tokens) — Semantic color scales, brand colors, state colors, and surface layers (see also [Three-Tier Color Strategy](../../../styling/color/three-tier-color-strategy) for the underlying architecture) - [Typography Token Patterns](./typography-tokens) — Font size, line-height, font-weight, and letter-spacing token strategies - [Token Preview](./token-preview) — Visual reference of all available tokens - [Component Tokens & Arbitrary Values](./component-tokens) — When to use system tokens vs arbitrary values - [Two-Tier Size Strategy](../two-tier-size-strategy/) — Why width/height sizing skips the abstract layer and uses semantic theme tokens directly ## References - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # Container Queries > Source: https://takazudomodular.com/pj/zcss/docs/responsive/container-queries ## The Problem Media queries respond to the viewport width, not the width of the component's container. When a component is placed in a sidebar, a modal, or any constrained layout, viewport-based media queries cannot adapt the component's layout to its actual available space. AI agents almost always reach for `@media` queries for component-level responsiveness, ignoring container queries entirely. ## The Solution CSS Container Queries (`@container`) allow components to respond to the size of their parent container rather than the viewport. This makes components truly reusable across different layout contexts. Container queries are Baseline 2023 and supported in all modern browsers. ### Setting Up a Container A parent element must be declared as a containment context using `container-type`. The most common value is `inline-size`, which enables queries based on the container's inline (horizontal) dimension. ```css .card-wrapper { container-type: inline-size; } ``` ### Querying the Container ```css @container (min-width: 400px) { .card { display: grid; grid-template-columns: 200px 1fr; } } ``` ### Basic Container Query The iframe in this demo acts as the container boundary. Use the viewport buttons to see the card layout adapt to the container width. Responsive Card This card uses container queries to adapt its layout. At narrow widths it stacks vertically; at wider widths it switches to a horizontal layout. `} css={` .card-wrapper { container-type: inline-size; padding: 1rem; } .card { display: flex; flex-direction: column; border-radius: 0.5rem; overflow: hidden; background: #f8fafc; border: 1px solid #e2e8f0; } .card__image { width: 100%; height: 120px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); } .card__body { padding: 1rem; } .card__title { font-size: 1.125rem; font-weight: 700; margin: 0 0 0.5rem; color: #1e293b; } .card__text { font-size: 0.875rem; color: #64748b; margin: 0; line-height: 1.5; } @container (min-width: 500px) { .card { flex-direction: row; } .card__image { width: 200px; height: auto; min-height: 150px; } } @container (min-width: 700px) { .card { gap: 1rem; } .card__image { width: 280px; } .card__title { font-size: 1.375rem; } } `} /> ## Named Containers When containers are nested, `@container` queries match the nearest ancestor with `container-type` set. To target a specific container, use `container-name` and reference it in the query. ```css .sidebar { container-type: inline-size; container-name: sidebar; } .main-content { container-type: inline-size; container-name: main; } /* Only responds to the sidebar container */ @container sidebar (max-width: 300px) { .nav-list { flex-direction: column; } } ``` The shorthand `container` property combines both: ```css .sidebar { container: sidebar / inline-size; } ``` Sidebar (narrow container) ★ Featured This card adapts to its sidebar container Main Content (wide container) ★ Featured Same card component adapts to the wider main container, showing a horizontal layout with more space `} css={` .page-layout { display: grid; grid-template-columns: 180px 1fr; gap: 1rem; padding: 1rem; } .sidebar { container: sidebar / inline-size; } .main-content { container: main / inline-size; } .section-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin: 0 0 0.5rem; } .info-card { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.5rem; padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 0.5rem; } .info-card__icon { font-size: 1.5rem; color: #f59e0b; line-height: 1; } .info-card__content { display: flex; flex-direction: column; gap: 0.25rem; } .info-card__title { font-size: 0.875rem; color: #1e293b; } .info-card__text { font-size: 0.75rem; color: #64748b; line-height: 1.4; } @container main (min-width: 300px) { .info-card { flex-direction: row; text-align: left; align-items: flex-start; } } `} height={220} /> ## Container Query Units Container query units are relative to the dimensions of the query container. These are useful for fluid sizing within a component. - `cqw` — 1% of the container's width - `cqh` — 1% of the container's height - `cqi` — 1% of the container's inline size - `cqb` — 1% of the container's block size - `cqmin` — the smaller of `cqi` or `cqb` - `cqmax` — the larger of `cqi` or `cqb` ```css .card-container { container-type: inline-size; } .card__title { /* 5% of the container's inline size, clamped */ font-size: clamp(1rem, 5cqi, 2rem); } .card__body { /* Padding relative to container width */ padding: 2cqi; } ``` Fluid Title This text and padding scale with the container width using cqi units. Resize using the viewport buttons to see everything scale proportionally. cqi sized `} css={` .cqu-demo { container-type: inline-size; padding: 1rem; } .cqu-card { background: linear-gradient(135deg, #1e293b, #334155); color: white; padding: clamp(0.75rem, 4cqi, 2.5rem); border-radius: clamp(0.375rem, 1.5cqi, 1rem); } .cqu-card__title { font-size: clamp(1rem, 5cqi, 2.25rem); font-weight: 700; margin: 0 0 clamp(0.25rem, 1.5cqi, 0.75rem); line-height: 1.2; } .cqu-card__text { font-size: clamp(0.75rem, 2.5cqi, 1.125rem); color: #cbd5e1; margin: 0 0 clamp(0.5rem, 2cqi, 1rem); line-height: 1.5; } .cqu-card__badge { display: inline-block; background: #3b82f6; color: white; font-size: clamp(0.625rem, 2cqi, 0.875rem); font-weight: 600; padding: clamp(0.125rem, 0.5cqi, 0.375rem) clamp(0.375rem, 1.5cqi, 0.75rem); border-radius: 9999px; } `} /> ## Card Component Adapting to Container Width A common real-world use case is a card component that works in any layout context: a narrow sidebar, a medium-width grid column, or a full-width main area. Mountain Retreat A peaceful getaway nestled in the mountains with stunning views and fresh mountain air. $120 / night Forest Cabin A cozy cabin surrounded by towering trees and natural beauty. $95 / night Beach House Oceanfront living with direct beach access and sunset views from every room. $200 / night `} css={` .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 1rem; padding: 1rem; } .card-cell { container-type: inline-size; } .adaptive-card { display: flex; flex-direction: column; border-radius: 0.5rem; overflow: hidden; background: #ffffff; border: 1px solid #e2e8f0; height: 100%; } .adaptive-card__media { width: 100%; height: 100px; background: linear-gradient(135deg, #3b82f6, #6366f1); } .adaptive-card__body { padding: 0.75rem; display: flex; flex-direction: column; flex: 1; } .adaptive-card__title { font-size: 1rem; font-weight: 700; margin: 0 0 0.375rem; color: #1e293b; } .adaptive-card__desc { font-size: 0.8125rem; color: #64748b; margin: 0 0 0.75rem; line-height: 1.4; flex: 1; } .adaptive-card__price { font-size: 0.875rem; font-weight: 700; color: #059669; } @container (min-width: 350px) { .adaptive-card { flex-direction: row; } .adaptive-card__media { width: 140px; height: auto; min-height: 120px; } .adaptive-card__body { padding: 1rem; } } `} height={400} /> ## Container Queries vs. Media Queries The key difference: media queries respond to the **viewport**, while container queries respond to the **parent container**. This demo places the same component in two different-width containers on the same page. The media query version looks identical in both because the viewport hasn't changed. The container query version adapts to each container independently. Using @container (adapts to each container) Container Query Card Narrow container Container Query Card Wide container Using @media (both look the same) Media Query Card Narrow container Media Query Card Wide container `} css={` .demo-heading { font-size: 0.8125rem; font-weight: 700; color: #475569; margin: 0 0 0.5rem; padding: 0 0.75rem; } .comparison-layout { display: grid; grid-template-columns: 180px 1fr; gap: 0.75rem; padding: 0 0.75rem; } .narrow-container { min-width: 0; } .wide-container { min-width: 0; } /* ---- Container Query version ---- */ .cq-container { container-type: inline-size; } .cq-card { display: flex; flex-direction: column; border: 1px solid #bfdbfe; border-radius: 0.375rem; overflow: hidden; background: #eff6ff; } .cq-card__image { width: 100%; height: 48px; background: #3b82f6; } .cq-card__body { padding: 0.5rem; display: flex; flex-direction: column; gap: 0.125rem; } .cq-card__body strong { font-size: 0.75rem; color: #1e293b; } .cq-card__body span { font-size: 0.6875rem; color: #64748b; } @container (min-width: 300px) { .cq-card { flex-direction: row; } .cq-card__image { width: 80px; height: auto; min-height: 60px; } } /* ---- Media Query version ---- */ .mq-card { display: flex; flex-direction: column; border: 1px solid #fecaca; border-radius: 0.375rem; overflow: hidden; background: #fef2f2; } .mq-card__image { width: 100%; height: 48px; background: #ef4444; } .mq-card__body { padding: 0.5rem; display: flex; flex-direction: column; gap: 0.125rem; } .mq-card__body strong { font-size: 0.75rem; color: #1e293b; } .mq-card__body span { font-size: 0.6875rem; color: #64748b; } @media (min-width: 300px) { .mq-card { flex-direction: row; } .mq-card__image { width: 80px; height: auto; min-height: 60px; } } `} height={380} /> In the demo above, the `@container` cards adapt independently: the card in the narrow container stacks vertically while the card in the wide container goes horizontal. The `@media` cards both go horizontal because the viewport (the iframe) is wider than `300px` — neither card knows how wide its actual container is. ## Code Examples ### Responsive Card Component ```css .card-container { container-type: inline-size; container-name: card; } /* Base: stacked layout */ .card { display: flex; flex-direction: column; } .card__image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } /* When container is wide enough: horizontal layout */ @container card (min-width: 500px) { .card { flex-direction: row; } .card__image { width: 200px; aspect-ratio: 1; } } /* When container is very wide: add extra spacing */ @container card (min-width: 800px) { .card { gap: 2rem; padding: 2rem; } .card__image { width: 300px; } } ``` ### Navigation That Adapts to Its Container ```css .nav-wrapper { container-type: inline-size; container-name: nav; } .nav-list { display: flex; flex-direction: column; gap: 0.25rem; list-style: none; padding: 0; margin: 0; } /* Horizontal layout when container allows */ @container nav (min-width: 600px) { .nav-list { flex-direction: row; gap: 1rem; } } ``` ### Combining Container Queries with Container Query Units ```css .widget-wrapper { container: widget / inline-size; } .widget__title { font-size: clamp(1rem, 5cqi, 2rem); } .widget__body { padding: clamp(0.5rem, 3cqi, 1.5rem); } @container widget (min-width: 400px) { .widget { display: grid; grid-template-columns: auto 1fr; gap: 1rem; } } ``` ## Common AI Mistakes - **Using media queries for component layouts**: AI agents default to `@media` queries even when the component needs to adapt to its container, not the viewport. - **Forgetting `container-type`**: Writing `@container` rules without setting `container-type` on the parent element. The container must be explicitly declared. - **Using `container-type: size` unnecessarily**: Height-based containment (`size`) can cause layout issues. Use `inline-size` for the vast majority of cases. - **Not naming nested containers**: When containers are nested, omitting `container-name` leads to ambiguity — `@container` queries match the nearest ancestor container. Name containers when nesting to target specific ancestors. - **Querying the element itself**: The `@container` query targets the nearest ancestor with `container-type` set, not the element you are styling. The container and the styled element must be different elements. ## When to Use - **Component-level responsiveness**: Any reusable component that may appear in different layout widths (cards, navigation, form groups). - **Sidebar vs. main content**: When the same component appears in both wide and narrow contexts on the same page. - **Design system components**: Components built for reuse across different applications and layouts. - **Not for page-level layout**: Continue using `@media` queries for macro layout concerns like switching between single-column and multi-column page layouts. ## References - [CSS Container Queries — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries) - [@container — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container) - [Container Query Units — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_query_length_units) - [Container Queries Unleashed — Josh W. Comeau](https://www.joshwcomeau.com/css/container-queries-unleashed/) - [CSS Container Queries — CSS-Tricks](https://css-tricks.com/css-container-queries/) --- # OKLCH Color Space > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/oklch-color-space ## The Problem AI agents almost always generate colors in `hex`, `rgb()`, or `hsl()` format. These older color spaces have a fundamental flaw: they are not perceptually uniform. In HSL, two colors with the same lightness value (e.g., `hsl(60, 100%, 50%)` yellow and `hsl(240, 100%, 50%)` blue) appear drastically different in perceived brightness. This makes it nearly impossible to create consistent, accessible color palettes by simply adjusting hue values. AI-generated palettes in HSL often have inconsistent contrast ratios, muddy mid-tones, and colors that "jump" in perceived brightness across the spectrum. ## The Solution OKLCH (`oklch()`) is a CSS color function based on the Oklab perceptual color model. It uses three components: - **L** — Lightness (0% = black, 100% = white), perceptually linear - **C** — Chroma (0 = gray, higher = more vivid), represents colorfulness - **H** — Hue (0–360 degrees), the color angle on the color wheel The key advantage: if you keep L constant and change H, the perceived brightness stays the same. This makes palette creation predictable — you can generate a set of colors that look equally bright to the human eye. ### Why OKLCH Beats HSL ```css /* HSL: These "look" like the same lightness, but they're not */ .yellow { color: hsl(60, 100%, 50%); /* Appears very bright */ } .blue { color: hsl(240, 100%, 50%); /* Appears much darker */ } /* OKLCH: Same lightness = same perceived brightness */ .yellow { color: oklch(80% 0.18 90); /* Visually bright */ } .blue { color: oklch(80% 0.18 264); /* Equally bright */ } ``` ## Code Examples ### Basic OKLCH Syntax ```css :root { /* oklch(lightness chroma hue) */ --brand-primary: oklch(55% 0.25 264); /* Vivid blue */ --brand-secondary: oklch(65% 0.2 150); /* Teal-green */ --brand-accent: oklch(70% 0.22 30); /* Warm orange */ /* With alpha transparency */ --overlay: oklch(20% 0 0 / 0.5); /* Semi-transparent black */ } ``` ### Creating a Perceptually Uniform Palette By fixing lightness and chroma and only rotating hue, every color has the same visual weight: ```css :root { /* Categorical palette — all colors appear equally prominent */ --chart-1: oklch(65% 0.2 30); /* Red-orange */ --chart-2: oklch(65% 0.2 90); /* Yellow */ --chart-3: oklch(65% 0.2 150); /* Green */ --chart-4: oklch(65% 0.2 210); /* Cyan */ --chart-5: oklch(65% 0.2 270); /* Blue */ --chart-6: oklch(65% 0.2 330); /* Magenta */ } ``` OKLCH — Same lightness (65%), different hues Red H:30 Yellow H:90 Green H:150 Cyan H:210 Blue H:270 Magenta H:330 All colors appear equally bright — perceptually uniform HSL — Same lightness (50%), different hues Red H:0 Yellow H:60 Green H:120 Cyan H:180 Blue H:240 Magenta H:300 Yellow appears much brighter than blue — not perceptually uniform `} css={`.color-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.5rem; } .color-demo h3 { font-size: 0.85rem; color: #444; margin: 0 0 0.75rem; font-weight: 600; } .swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.5rem; } .swatch { aspect-ratio: 1; border-radius: 8px; display: flex; align-items: flex-end; justify-content: center; padding: 0.25rem; } .swatch span { font-size: 0.65rem; color: white; text-shadow: 0 1px 3px rgba(0,0,0,0.6); font-weight: 600; } .note { font-size: 0.8rem; color: #666; margin: 0.5rem 0 0; font-style: italic; }`} height={340} /> ### Lightness Scale for a Single Hue ```css :root { --blue-hue: 264; --blue-chroma: 0.15; --blue-50: oklch(97% var(--blue-chroma) var(--blue-hue)); --blue-100: oklch(93% var(--blue-chroma) var(--blue-hue)); --blue-200: oklch(85% var(--blue-chroma) var(--blue-hue)); --blue-300: oklch(75% var(--blue-chroma) var(--blue-hue)); --blue-400: oklch(65% var(--blue-chroma) var(--blue-hue)); --blue-500: oklch(55% var(--blue-chroma) var(--blue-hue)); --blue-600: oklch(45% var(--blue-chroma) var(--blue-hue)); --blue-700: oklch(37% var(--blue-chroma) var(--blue-hue)); --blue-800: oklch(30% var(--blue-chroma) var(--blue-hue)); --blue-900: oklch(22% var(--blue-chroma) var(--blue-hue)); } ``` ### Theming with OKLCH Custom Properties ```css :root { --hue: 264; --chroma: 0.2; --color-primary: oklch(55% var(--chroma) var(--hue)); --color-primary-light: oklch(75% var(--chroma) var(--hue)); --color-primary-dark: oklch(35% var(--chroma) var(--hue)); --color-primary-subtle: oklch(95% 0.03 var(--hue)); --color-surface: oklch(99% 0.005 var(--hue)); --color-text: oklch(20% 0.02 var(--hue)); --color-text-muted: oklch(45% 0.02 var(--hue)); } /* Change the entire theme by adjusting one variable */ .theme-green { --hue: 150; } .theme-red { --hue: 25; } ``` ### Accessible Color Pairs With OKLCH, you can guarantee contrast by controlling the lightness delta: ```css :root { /* A lightness difference of ~45-50% in oklch roughly maps to WCAG AA 4.5:1 */ --bg: oklch(97% 0.01 264); --text: oklch(25% 0.02 264); --btn-bg: oklch(50% 0.2 264); --btn-text: oklch(98% 0.01 264); } ``` ### OKLCH vs HSL — Real Comparison ```css /* Creating "same lightness" grays in HSL — they're not truly equal */ .hsl-problem { --gray-warm: hsl(30, 10%, 50%); --gray-cool: hsl(210, 10%, 50%); /* These two grays have visibly different perceived brightness */ } /* OKLCH grays are genuinely perceptually matched */ .oklch-solution { --gray-warm: oklch(55% 0.02 60); --gray-cool: oklch(55% 0.02 250); /* These two grays actually look equally bright */ } ``` ## Common AI Mistakes - Defaulting to `hex` or `hsl()` for all color values when `oklch()` would produce more consistent palettes - Assuming HSL lightness is perceptually uniform — `hsl(60, 100%, 50%)` and `hsl(240, 100%, 50%)` look vastly different in brightness despite identical lightness values - Using chroma values that exceed the gamut for certain hue/lightness combinations — the browser will clip them, but the result may differ from intent - Not taking advantage of OKLCH's hue rotation for generating multi-color palettes — AI often hard-codes each color independently instead of rotating hue - Creating color scales by evenly spacing lightness values (10%, 20%, 30%...) without considering that very high chroma at extreme lightness is out of gamut - Using `oklch(0% 0 0)` and `oklch(100% 0 0)` for black and white when simpler `black` and `white` keywords suffice ## When to Use - **Design system color tokens**: OKLCH makes it straightforward to generate consistent lightness scales across different hues - **Data visualization palettes**: Categorical colors at the same perceived brightness prevent one color from dominating visually - **Accessible theming**: Controlling the lightness delta between background and text ensures predictable contrast - **Dynamic theming**: Rotating the hue custom property shifts the entire palette while preserving visual harmony ### When to stay with hex/rgb - When targeting older browsers that don't support OKLCH (pre-2023) and a fallback is impractical - When interfacing with design tools or APIs that only accept hex or rgb values - Single-color declarations where perceptual uniformity is irrelevant ## References - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH in CSS: why we moved from RGB and HSL — Evil Martians](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) - [OKLCH Color Picker & Converter](https://oklch.com/) - [CSS-Tricks: oklch()](https://css-tricks.com/almanac/functions/o/oklch/) - [Oklab color space — Wikipedia](https://en.wikipedia.org/wiki/Oklab_color_space) --- # Backdrop Filter and Glassmorphism > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/backdrop-filter-and-glassmorphism ## The Problem Glassmorphism — the frosted-glass aesthetic popularized by Apple's iOS and macOS — requires `backdrop-filter: blur()` combined with a semi-transparent background. AI agents frequently get this wrong in several ways: they apply blur to the element itself instead of the backdrop, use fully opaque backgrounds that hide the blur entirely, forget the `-webkit-` prefix needed for Safari, or apply the effect on solid-colored backgrounds where there is nothing interesting to blur. ## The Solution Use `backdrop-filter: blur()` on a semi-transparent element positioned over a visually rich background (gradients, images, or colorful content). The blur applies to everything **behind** the element, not the element's own content. Keep blur values between 8–16px for the best balance of aesthetics and performance, and always include the `-webkit-` prefix for Safari compatibility. ### Core Principles #### Semi-Transparent Background Required The element must have a semi-transparent `background-color` so the blurred backdrop shows through. A fully opaque background defeats the entire purpose. #### Rich Content Behind `backdrop-filter` blurs what is behind the element. Over a solid white page, there is nothing to blur and the effect is invisible. Always use glassmorphism over gradients, images, or layered colorful content. #### Performance Budget Each glassmorphic element triggers a separate GPU blur calculation. Limit to 2–3 glass elements per viewport. Avoid animating `backdrop-filter` values directly. ## Code Examples ### Basic Frosted Glass Card ```css .glass-card { background: hsl(0deg 0% 100% / 0.15); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.2); border-radius: 16px; padding: 24px; } ``` ```html Frosted Glass Content is readable over a blurred background. ``` ### Vibrant Background Setup The glass effect only works when there is rich visual content behind it. ```css .vibrant-background { min-height: 100vh; background: radial-gradient(circle at 20% 80%, hsl(280deg 80% 60% / 0.6), transparent 50%), radial-gradient(circle at 80% 20%, hsl(200deg 80% 60% / 0.6), transparent 50%), linear-gradient(135deg, hsl(220deg 60% 20%), hsl(280deg 60% 30%)); display: grid; place-items: center; padding: 40px; } ``` ### Dark Theme Glassmorphism ```css .glass-dark { background: hsl(220deg 20% 10% / 0.4); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid hsl(0deg 0% 100% / 0.08); border-radius: 12px; box-shadow: 0 8px 32px hsl(0deg 0% 0% / 0.3); } ``` ### Light Theme Glassmorphism ```css .glass-light { background: hsl(0deg 0% 100% / 0.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.3); border-radius: 12px; box-shadow: 0 4px 16px hsl(0deg 0% 0% / 0.08); } ``` ### Frosted Glass Navigation Bar ```css .glass-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: hsl(0deg 0% 100% / 0.7); backdrop-filter: blur(10px) saturate(180%); -webkit-backdrop-filter: blur(10px) saturate(180%); border-bottom: 1px solid hsl(0deg 0% 0% / 0.06); padding: 12px 24px; } ``` Adding `saturate(180%)` alongside `blur()` intensifies the colors of the blurred content, mimicking the vibrancy of Apple's glass effect. ### Glass Modal Overlay ```css .glass-overlay { position: fixed; inset: 0; background: hsl(0deg 0% 0% / 0.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 200; } .glass-modal { background: hsl(0deg 0% 100% / 0.2); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid hsl(0deg 0% 100% / 0.15); border-radius: 20px; padding: 32px; max-width: 500px; width: 90%; box-shadow: 0 16px 48px hsl(0deg 0% 0% / 0.2); } ``` ```html Modal Title Modal content over a frosted backdrop. ``` ### Performant Alternative with Pre-Blurred Background When performance is critical (mobile, many glass elements), use a pre-blurred image instead of runtime `backdrop-filter`. ```css .faux-glass { position: relative; overflow: hidden; border-radius: 16px; } .faux-glass::before { content: ""; position: absolute; inset: -20px; background: url("background-preblurred.jpg") center / cover; filter: blur(0); /* image is already blurred */ z-index: -1; } .faux-glass-content { position: relative; background: hsl(0deg 0% 100% / 0.15); padding: 24px; } ``` ## Live Previews Frosted GlassContent is readable over a blurred, colorful background.`} css={` .vibrant-bg { width: 100%; height: 100%; background: radial-gradient(circle at 20% 80%, hsl(280deg 80% 60% / 0.8), transparent 50%), radial-gradient(circle at 80% 20%, hsl(200deg 80% 60% / 0.8), transparent 50%), radial-gradient(circle at 50% 50%, hsl(340deg 80% 50% / 0.5), transparent 60%), linear-gradient(135deg, hsl(220deg 60% 20%), hsl(280deg 60% 30%)); display: flex; justify-content: center; align-items: center; padding: 24px; font-family: system-ui, sans-serif; } .glass-card { background: hsl(0deg 0% 100% / 0.15); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.25); border-radius: 16px; padding: 32px; max-width: 340px; color: white; } .glass-card h2 { margin: 0 0 8px; font-size: 22px; } .glass-card p { margin: 0; font-size: 14px; opacity: 0.9; } `} height={280} /> SettingsNotificationsONDark ModeONAuto-saveOFF`} css={` .vibrant-bg { width: 100%; height: 100%; background: radial-gradient(circle at 70% 30%, hsl(330deg 80% 50% / 0.6), transparent 50%), radial-gradient(circle at 30% 70%, hsl(180deg 80% 50% / 0.5), transparent 50%), linear-gradient(135deg, #0f172a, #1e1b4b); display: flex; justify-content: center; align-items: center; padding: 24px; font-family: system-ui, sans-serif; } .glass-panel { background: hsl(220deg 20% 10% / 0.4); backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); border: 1px solid hsl(0deg 0% 100% / 0.1); border-radius: 16px; padding: 24px; width: 280px; color: white; box-shadow: 0 8px 32px hsl(0deg 0% 0% / 0.3); } .glass-panel h3 { margin: 0 0 16px; font-size: 18px; font-weight: 600; } .item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid hsl(0deg 0% 100% / 0.08); font-size: 14px; } .item:last-child { border-bottom: none; } .toggle { background: hsl(150deg 60% 45% / 0.3); color: hsl(150deg 60% 70%); padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; } .toggle.off { background: hsl(0deg 0% 50% / 0.3); color: hsl(0deg 0% 70%); } `} height={300} /> ## Common AI Mistakes - **Using `filter: blur()` instead of `backdrop-filter: blur()`** — `filter` blurs the element itself and all its content, making text unreadable. `backdrop-filter` blurs only what is behind the element. - **Fully opaque background** — Setting `background: white` or `background: rgba(255, 255, 255, 1)` completely covers the blurred backdrop, making the effect invisible. - **Forgetting the `-webkit-` prefix** — Safari requires `-webkit-backdrop-filter` alongside the standard `backdrop-filter`. Without it, Safari users see no blur. - **Applying over solid backgrounds** — Glass over a single flat color produces no visible effect. There must be visual variation behind the element for the blur to be meaningful. - **Too many glass elements** — Each `backdrop-filter` triggers an expensive GPU blur pass. Using it on every card, button, and nav item causes severe frame drops. - **Excessive blur values** — Using `blur(40px)` or higher. Values above 16px are exponentially more expensive and rarely look better than 12–16px. - **Not adjusting for light and dark themes** — A glass effect tuned for a dark background looks washed out on a light background and vice versa. Background opacity and border need theme-specific adjustment. ## When to Use - Fixed navigation bars that scroll over varying page content - Modal overlays that need to maintain context of the underlying page - Hero section cards or overlays positioned over vibrant images or gradients - Sidebar panels in applications with rich visual content beneath - Tooltip or popover elements where maintaining spatial context matters ## Tailwind CSS Tailwind provides `backdrop-blur-*`, `backdrop-brightness-*`, and `backdrop-saturate-*` utilities for glassmorphism effects without writing custom CSS. ### Frosted Glass Card Frosted Glass Content is readable over a blurred, colorful background. `} height={300} /> ### Dark Glass Panel Settings Notifications ON Dark Mode ON Auto-save OFF `} height={320} /> ## References - [backdrop-filter — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) - [Next-level Frosted Glass with backdrop-filter — Josh W. Comeau](https://www.joshwcomeau.com/css/backdrop-filter/) - [Glassmorphism Design Trend: Implementation Guide — Developer Playground](https://playground.halfaccessible.com/blog/glassmorphism-design-trend-implementation-guide) - [Blending Modes in CSS — Ahmad Shadeed](https://ishadeed.com/article/blending-modes-css/) --- # CSS-Only Pattern Library > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/gradient-techniques/css-pattern-library A comprehensive collection of CSS-only decorative background patterns built entirely with gradients. Every pattern below tiles seamlessly using `background-size` and `background-repeat`, requires zero images, and can be customized through CSS custom properties. ## Diagonal Stripes `repeating-linear-gradient` at 45deg creates classic diagonal stripes. Two hard color stops at the same position produce an instant transition between colors. The repeating variant tiles the pattern automatically. `} css={` .diagonal-stripes { --stripe-color-1: hsl(220 70% 55%); --stripe-color-2: hsl(220 70% 40%); --stripe-width: 12px; width: 100%; height: 100%; background: repeating-linear-gradient( 45deg, var(--stripe-color-1) 0px, var(--stripe-color-1) var(--stripe-width), var(--stripe-color-2) var(--stripe-width), var(--stripe-color-2) calc(var(--stripe-width) * 2) ); } `} /> ## Horizontal Lines (Notebook Paper) A single `repeating-linear-gradient` draws evenly spaced horizontal lines on a white background, mimicking ruled notebook paper. The trick is using a transparent-to-transparent span with a thin colored slice for each rule line. The quick brown fox jumps over the lazy dog. CSS gradients can simulate notebook paper without any images. `} css={` .notebook { --line-color: hsl(210 40% 80%); --line-spacing: 28px; --line-width: 1px; width: 100%; height: 100%; padding: 20px 32px; background-color: hsl(45 50% 97%); background-image: repeating-linear-gradient( to bottom, transparent 0px, transparent calc(var(--line-spacing) - var(--line-width)), var(--line-color) calc(var(--line-spacing) - var(--line-width)), var(--line-color) var(--line-spacing) ); background-size: 100% var(--line-spacing); background-position: 0 10px; } .notebook-text { font-family: 'Georgia', serif; font-size: 15px; line-height: 28px; color: hsl(220 20% 30%); margin: 0; } `} /> ## Polka Dots Two offset `radial-gradient` layers create an evenly spaced dot pattern. The second layer is shifted by half the tile size in both directions so dots fall between the gaps of the first layer. `} css={` .polka-dots { --dot-color: hsl(340 70% 60%); --bg-color: hsl(340 30% 95%); --dot-size: 12px; --dot-spacing: 36px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: radial-gradient( circle, var(--dot-color) var(--dot-size), transparent var(--dot-size) ), radial-gradient( circle, var(--dot-color) var(--dot-size), transparent var(--dot-size) ); background-size: var(--dot-spacing) var(--dot-spacing); background-position: 0 0, calc(var(--dot-spacing) / 2) calc(var(--dot-spacing) / 2); } `} /> ## Checkerboard `conic-gradient` with four hard stops at 25% intervals produces a two-color quadrant in each tile. When repeated via `background-size`, the quadrants align into a classic checkerboard. `} css={` .checkerboard { --color-1: hsl(0 0% 15%); --color-2: hsl(0 0% 95%); --tile-size: 40px; width: 100%; height: 100%; background-image: conic-gradient( var(--color-1) 25%, var(--color-2) 25% 50%, var(--color-1) 50% 75%, var(--color-2) 75% ); background-size: var(--tile-size) var(--tile-size); } `} /> ## Zigzag / Sawtooth Edge Two `linear-gradient` triangles placed side by side create a zigzag edge. By applying them as a background on a pseudo-element, you get a decorative sawtooth border without any extra markup. Zigzag bottom edge using gradient triangles `} css={` .zigzag-card { --zigzag-color: hsl(250 60% 50%); --zigzag-size: 16px; width: 100%; position: relative; background: var(--zigzag-color); padding: 32px 24px; padding-bottom: calc(32px + var(--zigzag-size)); } .zigzag-content { color: hsl(0 0% 100%); font-family: system-ui, sans-serif; font-size: 18px; font-weight: 600; } .zigzag-card::after { content: ''; position: absolute; bottom: 0; left: 0; width: 100%; height: var(--zigzag-size); background: linear-gradient( 135deg, var(--zigzag-color) 33.33%, transparent 33.33% ), linear-gradient( 225deg, var(--zigzag-color) 33.33%, transparent 33.33% ); background-size: calc(var(--zigzag-size) * 2) 100%; background-position: left bottom; transform: translateY(100%); } `} /> ## Carbon Fiber Layered `radial-gradient` and `linear-gradient` simulate a carbon fiber weave. A subtle radial dot grid sits on top of a striped linear gradient, all over a dark base color. `} css={` .carbon-fiber { --highlight: hsl(0 0% 22%); --base: hsl(0 0% 12%); --dot: hsl(0 0% 17%); --dot-size: 2px; --cell-size: 8px; width: 100%; height: 100%; background: radial-gradient( circle, var(--dot) var(--dot-size), transparent var(--dot-size) ), radial-gradient( circle, var(--dot) var(--dot-size), transparent var(--dot-size) ), repeating-linear-gradient( to bottom, transparent 0px, transparent calc(var(--cell-size) / 2), var(--highlight) calc(var(--cell-size) / 2), var(--highlight) var(--cell-size) ), var(--base); background-size: var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size); background-position: 0 0, calc(var(--cell-size) / 2) calc(var(--cell-size) / 2), 0 0; } `} /> ## Grid / Graph Paper Two layered `linear-gradient` passes — one horizontal, one vertical — draw thin lines that intersect to form a grid. Adjusting the custom properties changes line weight, spacing, and color. `} css={` .graph-paper { --line-color: hsl(200 40% 75%); --bg-color: hsl(0 0% 100%); --cell-size: 24px; --line-width: 1px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: linear-gradient( to right, var(--line-color) 0px, var(--line-color) var(--line-width), transparent var(--line-width) ), linear-gradient( to bottom, var(--line-color) 0px, var(--line-color) var(--line-width), transparent var(--line-width) ); background-size: var(--cell-size) var(--cell-size); } `} /> ## Diamond / Argyle Two `linear-gradient` layers rotated to opposite diagonal angles create overlapping diamond shapes. A third gradient adds the classic argyle "stitch" lines between diamonds. `} css={` .argyle { --color-1: hsl(210 50% 45%); --color-2: hsl(210 50% 35%); --bg-color: hsl(210 50% 55%); --stitch-color: hsl(0 0% 100% / 0.15); --diamond-size: 60px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: repeating-linear-gradient( 120deg, var(--stitch-color) 0px, var(--stitch-color) 1px, transparent 1px, transparent 30px ), repeating-linear-gradient( 60deg, var(--stitch-color) 0px, var(--stitch-color) 1px, transparent 1px, transparent 30px ), linear-gradient( 45deg, var(--color-1) 25%, transparent 25%, transparent 75%, var(--color-1) 75% ), linear-gradient( -45deg, var(--color-2) 25%, transparent 25%, transparent 75%, var(--color-2) 75% ); background-size: var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size); } `} /> --- # Layered Natural Shadows > Source: https://takazudomodular.com/pj/zcss/docs/styling/shadows-and-borders/layered-natural-shadows ## The Problem Shadows are one of the most important depth cues in UI design, yet AI agents almost always generate a single, flat `box-shadow` declaration. A single shadow looks artificial because real-world shadows are not uniform blurs. When an object sits on a surface, it casts a tight, dark contact shadow near its base and a softer, lighter shadow that spreads further. A single `box-shadow` cannot reproduce this layered behavior. ## The Solution Use multiple comma-separated `box-shadow` values with progressively increasing blur radius and vertical offset. Each layer represents a different aspect of natural light behavior. Keep all shadows consistent with a single implied light source direction across the entire page. ### Core Principles #### Consistent Light Source Every shadow on the page should share the same ratio between horizontal and vertical offsets. A common convention is a light source above and slightly to the left, meaning vertical offset is roughly 2x the horizontal offset. #### Elevation Model As elements "rise" toward the viewer, three properties change: - **Offset increases** — the shadow moves further from the element - **Blur expands** — the shadow becomes softer and more diffused - **Opacity decreases** — the shadow fades as the element lifts higher #### Color-Matched Shadows Avoid pure black shadows (`rgba(0, 0, 0, ...)`). Instead, match the hue of the background at low saturation. This prevents the washed-out, desaturated appearance that black shadows cause. ## Code Examples ### Basic Layered Shadow ```css .card { box-shadow: 0 1px 1px hsl(0deg 0% 0% / 0.075), 0 2px 2px hsl(0deg 0% 0% / 0.075), 0 4px 4px hsl(0deg 0% 0% / 0.075), 0 8px 8px hsl(0deg 0% 0% / 0.075), 0 16px 16px hsl(0deg 0% 0% / 0.075); } ``` Each layer doubles the previous offset and blur. The cumulative effect is a smooth, natural-looking shadow with depth. ### Elevation Levels ```css /* Low elevation — resting on surface */ .elevation-1 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.07), 0 2px 2px hsl(220deg 60% 50% / 0.07), 0 4px 4px hsl(220deg 60% 50% / 0.07); } /* Medium elevation — card hover */ .elevation-2 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.06), 0 2px 2px hsl(220deg 60% 50% / 0.06), 0 4px 4px hsl(220deg 60% 50% / 0.06), 0 8px 8px hsl(220deg 60% 50% / 0.06), 0 16px 16px hsl(220deg 60% 50% / 0.06); } /* High elevation — modal / dialog */ .elevation-3 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.05), 0 2px 2px hsl(220deg 60% 50% / 0.05), 0 4px 4px hsl(220deg 60% 50% / 0.05), 0 8px 8px hsl(220deg 60% 50% / 0.05), 0 16px 16px hsl(220deg 60% 50% / 0.05), 0 32px 32px hsl(220deg 60% 50% / 0.05); } ``` ### Color-Matched Shadows on Colored Backgrounds ```css /* On a blue-tinted background */ .card-on-blue { background: hsl(220deg 80% 98%); box-shadow: 0 1px 2px hsl(220deg 60% 50% / 0.1), 0 3px 6px hsl(220deg 60% 50% / 0.08), 0 8px 16px hsl(220deg 60% 50% / 0.06); } /* On a warm background */ .card-on-warm { background: hsl(30deg 80% 98%); box-shadow: 0 1px 2px hsl(30deg 40% 40% / 0.1), 0 3px 6px hsl(30deg 40% 40% / 0.08), 0 8px 16px hsl(30deg 40% 40% / 0.06); } ``` ### Sharp + Diffuse Combination ```css /* Tight contact shadow + wide ambient shadow */ .card-sharp-diffuse { box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.12), 0 8px 24px hsl(0deg 0% 0% / 0.06); } ``` ### Complete Card Example ```html Card Title Card content goes here. ``` ```css .shadow-card { padding: 24px; border-radius: 8px; background: white; box-shadow: 0 0.5px 1px hsl(220deg 60% 50% / 0.06), 0 1px 2px hsl(220deg 60% 50% / 0.06), 0 2px 4px hsl(220deg 60% 50% / 0.06), 0 4px 8px hsl(220deg 60% 50% / 0.06), 0 8px 16px hsl(220deg 60% 50% / 0.06); transition: box-shadow 0.3s ease; } .shadow-card:hover { box-shadow: 0 1px 2px hsl(220deg 60% 50% / 0.05), 0 2px 4px hsl(220deg 60% 50% / 0.05), 0 4px 8px hsl(220deg 60% 50% / 0.05), 0 8px 16px hsl(220deg 60% 50% / 0.05), 0 16px 32px hsl(220deg 60% 50% / 0.05), 0 32px 64px hsl(220deg 60% 50% / 0.05); } ``` ## Live Preview Flat Shadow Single box-shadow value Layered Shadow Multiple layered values `} css={` .demo { display: flex; gap: 32px; justify-content: center; padding: 40px 20px; background: #f8fafc; font-family: system-ui, sans-serif; } .card { padding: 24px; border-radius: 8px; background: white; width: 200px; } .card h3 { font-size: 16px; font-weight: 600; margin-bottom: 8px; } .card p { font-size: 14px; color: #64748b; } .flat { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .layered { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.075), 0 2px 2px hsl(220deg 60% 50% / 0.075), 0 4px 4px hsl(220deg 60% 50% / 0.075), 0 8px 8px hsl(220deg 60% 50% / 0.075), 0 16px 16px hsl(220deg 60% 50% / 0.075); } `} /> ## Common AI Mistakes - **Single flat shadow** — Using `box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1)` everywhere. This produces a uniform, artificial look that lacks depth. - **Pure black shadow color** — `rgba(0, 0, 0, ...)` desaturates the area beneath the shadow, creating a gray, washed-out appearance over colored backgrounds. - **Inconsistent light direction** — Generating different offset angles for different elements, breaking the illusion of a unified light source. - **Same shadow for all elevations** — Using the same shadow for cards, modals, dropdowns, and tooltips, when each should have a distinct elevation level. - **Overly dark shadows** — Setting high opacity values (0.2-0.5) for a single shadow instead of distributing lower opacities across multiple layers. ## When to Use - Cards and raised surfaces that need to feel physically present - Elevation systems where multiple UI layers overlap (cards, dropdowns, modals, tooltips) - Hover states that should make an element appear to lift off the page - Any element that benefits from a sense of depth without feeling artificially heavy ## References - [Designing Beautiful Shadows in CSS — Josh W. Comeau](https://www.joshwcomeau.com/css/designing-shadows/) - [Smoother & Sharper Shadows with Layered Box-Shadows — Tobias Ahlin](https://tobiasahlin.com/blog/layered-smooth-box-shadows/) - [box-shadow — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) --- # Fluid Font Sizing with clamp() > Source: https://takazudomodular.com/pj/zcss/docs/typography/font-sizing/fluid-font-sizing ## The Problem Responsive typography traditionally requires multiple media queries to adjust font sizes at different breakpoints. This creates abrupt jumps between sizes and produces verbose, hard-to-maintain CSS. AI agents frequently generate fixed `px` or `rem` values at arbitrary breakpoints instead of using fluid scaling, resulting in text that is either too small on mobile or too large on desktop, with jarring transitions between breakpoints. ## The Solution The CSS `clamp()` function enables fluid typography in a single line, smoothly scaling font sizes between a minimum and maximum based on viewport width. The syntax is `clamp(min, preferred, max)`, where the preferred value typically combines a `rem` base with a `vw` component for viewport-relative scaling. ### The Formula The preferred (middle) value in `clamp()` should be calculated as a linear function of viewport width. The general formula is: ``` preferred = base-rem + (slope × 1vw) ``` Where the slope is derived from the desired font-size range and viewport range: ``` slope = (max-size - min-size) / (max-viewport - min-viewport) ``` ## Code Examples ### Basic Fluid Typography ```css /* Body text: scales from 16px to 20px between 320px and 1200px viewports */ body { font-size: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); } /* h1: scales from 28px to 48px */ h1 { font-size: clamp(1.75rem, 1.295rem + 2.27vw, 3rem); } /* h2: scales from 24px to 36px */ h2 { font-size: clamp(1.5rem, 1.227rem + 1.36vw, 2.25rem); } /* h3: scales from 20px to 28px */ h3 { font-size: clamp(1.25rem, 1.068rem + 0.91vw, 1.75rem); } ``` ### Fluid Type Scale System ```css :root { /* Viewport range: 320px to 1200px */ --step--1: clamp(0.833rem, 0.787rem + 0.23vw, 1rem); --step-0: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); --step-1: clamp(1.2rem, 1.042rem + 0.79vw, 1.563rem); --step-2: clamp(1.44rem, 1.186rem + 1.27vw, 1.953rem); --step-3: clamp(1.728rem, 1.339rem + 1.95vw, 2.441rem); --step-4: clamp(2.074rem, 1.494rem + 2.9vw, 3.052rem); } body { font-size: var(--step-0); } h1 { font-size: var(--step-4); } h2 { font-size: var(--step-3); } h3 { font-size: var(--step-2); } small { font-size: var(--step--1); } ``` Heading One Heading Two Heading Three Body text scales smoothly between minimum and maximum sizes as the viewport width changes. Try switching between Mobile, Tablet, and Full viewports to see the fluid scaling in action. `} css={`.fluid-demo { padding: 1.5rem; font-family: system-ui, sans-serif; } .fluid-demo h1 { font-size: clamp(1.75rem, 1.295rem + 2.27vw, 3rem); line-height: 1.2; margin: 0 0 0.5rem; color: #1a1a2e; } .fluid-demo h2 { font-size: clamp(1.5rem, 1.227rem + 1.36vw, 2.25rem); line-height: 1.25; margin: 0 0 0.5rem; color: #2d2d4e; } .fluid-demo h3 { font-size: clamp(1.25rem, 1.068rem + 0.91vw, 1.75rem); line-height: 1.3; margin: 0 0 0.5rem; color: #3d3d5c; } .fluid-demo p { font-size: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); line-height: 1.6; color: #444; margin: 0; }`} /> ### With Container Queries ```css /* Fluid sizing relative to container width instead of viewport */ .card-title { font-size: clamp(1rem, 0.5rem + 3cqi, 1.5rem); } ``` ## Common AI Mistakes - Using fixed `px` or `rem` values with media query breakpoints instead of `clamp()`, creating abrupt size jumps - Omitting the `rem` component in the preferred value (using only `vw`), which breaks zooming and accessibility - Setting minimum values too small (below `1rem` / 16px for body text), making text unreadable on mobile - Not testing the formula at extreme viewport widths, leading to absurdly large text on ultrawide monitors - Using `calc()` with `vw` alone (e.g., `calc(1rem + 1vw)`) without upper or lower bounds, which `clamp()` naturally provides - Applying fluid sizing to every text element when only headings and display text benefit from it — body text often works fine at a fixed `1rem` ## When to Use - Headings and display text that need to scale between mobile and desktop - Type scale systems where every step should fluidly adjust - Hero sections with large text that needs to shrink on small screens - Any scenario where breakpoint-based font-size changes cause visible jumps Avoid using `clamp()` for: - Body text where `1rem` is perfectly adequate at all sizes - Text inside components with fixed dimensions - Situations where precise control at specific breakpoints is required ## References - [MDN: clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Modern Fluid Typography Using CSS Clamp — Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Linearly Scale font-size with CSS clamp() — CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Fluid Type Scale Calculator](https://www.fluid-type-scale.com/) - [Utopia — Fluid Responsive Design](https://utopia.fyi/) --- # Font Loading Strategies > Source: https://takazudomodular.com/pj/zcss/docs/typography/fonts/font-loading-strategies ## The Problem Web fonts are a major source of layout shift (CLS) and poor perceived performance. When a browser downloads a custom font, it must decide what to show while waiting — invisible text (FOIT: Flash of Invisible Text) or fallback-styled text (FOUT: Flash of Unstyled Text). AI agents typically add a `@font-face` declaration or a Google Fonts `` tag and consider the job done, ignoring the loading behavior entirely. This produces visible layout shifts when fonts load, blank text during the loading period, or unnecessary network requests for fonts that could be deferred. ## The Solution A robust font loading strategy combines several techniques: the `font-display` descriptor to control rendering behavior, `` for critical fonts, system font stacks as fallbacks, and metric overrides to minimize layout shift. ## Code Examples ### font-display Values ```css @font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2") format("woff2"); /* swap: Show fallback immediately, swap when font loads. Best for body text where content must be readable. */ font-display: swap; } @font-face { font-family: "HeadingFont"; src: url("/fonts/heading.woff2") format("woff2"); /* optional: Use the font only if it's already cached. Best for non-critical text where layout stability matters more. */ font-display: optional; } ``` #### Summary of font-display values | Value | Block period | Swap period | Best for | | ---------- | ------------ | ----------- | -------------------------------- | | `auto` | Browser default | Browser default | Rarely the right choice | | `block` | Short (3s) | Infinite | Icon fonts only | | `swap` | Extremely short | Infinite | Body text, content fonts | | `fallback` | Very short (100ms) | Short (3s) | Balancing FOUT and CLS | | `optional` | None | None | Non-critical fonts, max CLS control | ### Preloading Critical Fonts ```html ``` The `crossorigin` attribute is required even for same-origin fonts — without it, the font will be fetched twice. system-ui (Default System Font) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 ui-monospace (System Monospace) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 Georgia (Serif Fallback) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 `} css={`.font-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; font-family: system-ui, sans-serif; } .font-card { background: #f8f9fa; border-radius: 8px; padding: 1rem; border-left: 4px solid #6c63ff; } .font-card h3 { font-size: 0.8rem; color: #6c63ff; margin: 0 0 0.5rem; font-weight: 600; } .font-card p { font-size: 1.1rem; line-height: 1.5; color: #333; margin: 0; }`} height={280} /> ### System Font Stack as Fallback ```css :root { --font-system: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; } body { font-family: "MyFont", var(--font-system); } code { font-family: var(--font-mono); } ``` ### Reducing Layout Shift with Metric Overrides ```css @font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2") format("woff2"); font-display: swap; } /* Adjust the fallback font metrics to match the web font */ @font-face { font-family: "MyFont Fallback"; src: local("Arial"); size-adjust: 104.7%; ascent-override: 93%; descent-override: 25%; line-gap-override: 0%; } body { font-family: "MyFont", "MyFont Fallback", sans-serif; } ``` ### Complete Strategy: Optimal Performance ```html @font-face { font-family: "Body"; src: url("/fonts/body-regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "Body"; src: url("/fonts/body-bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } @font-face { font-family: "Heading"; src: url("/fonts/heading.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: optional; } ``` ### Font Subsetting ```css /* Latin subset only — significantly reduces file size */ @font-face { font-family: "MyFont"; src: url("/fonts/myfont-latin.woff2") format("woff2"); font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } ``` ## Common AI Mistakes - Not specifying `font-display` at all, leaving the browser to use its default behavior (typically `auto`, which causes FOIT in most browsers) - Preloading every font weight and style, which congests the network and can actually slow down the page - Missing the `crossorigin` attribute on ``, causing the font to be downloaded twice - Using only `woff` instead of `woff2` — the latter provides 15-30% better compression and is supported by all modern browsers - Loading Google Fonts via `` without `display=swap` parameter (e.g., `fonts.googleapis.com/css2?family=Roboto&display=swap`) - Not providing a system font fallback stack, leaving `sans-serif` as the only fallback - Including fonts for weights that are never used in the design (e.g., loading 6 weights when only regular and bold are used) - Using `font-display: block` for body text, causing invisible text for up to 3 seconds on slow connections ## When to Use ### font-display: swap - Body text and primary reading content - Any text that must be immediately readable ### font-display: optional - Heading or display fonts where layout stability is critical - Fonts used for decorative purposes - Return visits where the font is likely cached ### Preloading - The single most critical font file (usually body regular weight) - Above-the-fold heading fonts on landing pages - Never more than 1-2 font files ### System font stacks - When performance is the top priority - Internal tools and admin interfaces - Fallback chains for custom web fonts ## References - [Best practices for fonts — web.dev](https://web.dev/articles/font-best-practices) - [The Best Font Loading Strategies — CSS-Tricks](https://css-tricks.com/the-best-font-loading-strategies-and-how-to-execute-them/) - [A Comprehensive Guide to Font Loading Strategies — Zach Leatherman](https://www.zachleat.com/web/comprehensive-webfonts) - [Ensure text remains visible during webfont load — Chrome Developers](https://developer.chrome.com/docs/lighthouse/performance/font-display) - [A New Way To Reduce Font Loading Impact — Smashing Magazine](https://www.smashingmagazine.com/2021/05/reduce-font-loading-impact-css-descriptors/) --- # Text Overflow and Line Clamping > Source: https://takazudomodular.com/pj/zcss/docs/typography/text-control/text-overflow-and-clamping ## The Problem Truncating text to fit constrained UI areas — cards, list items, navigation — is a common requirement. AI agents often reach for JavaScript-based solutions or generate incomplete CSS that only handles single-line truncation. Multi-line clamping, in particular, requires specific property combinations that are easy to get wrong. The legacy `-webkit-line-clamp` approach has three required co-dependent properties, and omitting any one of them causes silent failure. ## The Solution CSS provides two main truncation patterns: single-line ellipsis using `text-overflow` and multi-line clamping using `-webkit-line-clamp` (with the standard `line-clamp` property arriving for broader adoption). ## Code Examples ### Single-Line Ellipsis ```css .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } ``` All three properties are required: - `white-space: nowrap` prevents line wrapping - `overflow: hidden` clips the overflowing content - `text-overflow: ellipsis` displays the `...` indicator ```html This is a very long text that will be truncated with an ellipsis at the end ``` ### Multi-Line Clamping (Legacy Syntax) ```css .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } ``` All four properties are required for this pattern to work. This syntax, despite using `-webkit-` prefixes, is supported across all major browsers (Chrome, Firefox, Safari, Edge) and is a fully specified behavior. ```html Card Title Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. ``` Single-line Ellipsis This is a very long single line of text that will be truncated with an ellipsis at the end when it overflows the container width 2-line Clamp Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 3-line Clamp Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. `} css={`.overflow-demo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .demo-card { background: #f0f4ff; border-radius: 8px; padding: 1rem; } .demo-card h3 { font-size: 0.85rem; color: #4f46e5; margin: 0 0 0.75rem; font-weight: 600; } .demo-card p { font-size: 0.9rem; color: #333; margin: 0; } .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }`} height={260} /> ### Modern `line-clamp` Property The standard `line-clamp` property simplifies the syntax. As of 2025, Chromium-based browsers support this. ```css .line-clamp-modern { line-clamp: 3; overflow: hidden; } ``` ### Cross-Browser Safe Pattern For maximum compatibility, combine both approaches: ```css .line-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-clamp: 3; } ``` ### Practical Card Component ```css .card { max-width: 320px; padding: 1rem; } .card__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card__description { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } ``` ```html A very long card title that might overflow Card description text that can span multiple lines but will be clamped to exactly three lines with an ellipsis at the end of the third line. ``` ### Expandable Clamped Text ```css .expandable { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .expandable.is-expanded { -webkit-line-clamp: unset; } ``` ### Handling Clamped Text Accessibility ```html Truncated visible content... ``` ## Common AI Mistakes - Forgetting one of the three required properties for single-line truncation — all of `white-space`, `overflow`, and `text-overflow` must be set - Missing `display: -webkit-box` or `-webkit-box-orient: vertical` in multi-line clamping, causing the clamp to silently fail - Using JavaScript to truncate text by character count instead of CSS, which breaks at different font sizes and screen widths - Not setting a width constraint on the container — `text-overflow: ellipsis` requires the element to have a bounded width (either explicit or from a flex/grid parent) - Using `overflow: hidden` without `text-overflow: ellipsis`, which clips text mid-character without any visual indicator - Applying `-webkit-line-clamp` to inline elements — it requires a block-level box with the `-webkit-box` display model - Not considering that clamped text hides content from screen readers — the full text is still in the DOM, but visual-only users lose context about how much is hidden ## When to Use ### Single-line truncation - Navigation items with dynamic labels - Table cells with variable-width content - Tags and badges with constrained widths - Breadcrumb links ### Multi-line clamping - Card descriptions in grid layouts - Comment previews in social interfaces - Product descriptions in listing pages - Article excerpts or teasers ### When NOT to truncate - Primary content that users need to read in full - Error messages and validation text - Accessibility-critical labels and instructions - Content where the truncated portion changes meaning (e.g., prices, dates) ## Tailwind CSS Tailwind provides `truncate` for single-line ellipsis and `line-clamp-*` utilities for multi-line clamping. truncate This is a very long single line of text that will be truncated with an ellipsis at the end when it overflows line-clamp-2 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. line-clamp-3 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. `} height={200} /> ## References - [MDN: text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow) - [MDN: -webkit-line-clamp](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp) - [MDN: line-clamp](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/line-clamp) - [CSS-Tricks: line-clamp](https://css-tricks.com/almanac/properties/l/line-clamp/) - [How to use CSS line-clamp — LogRocket](https://blog.logrocket.com/css-line-clamp/) --- # /CLAUDE.md > Source: https://takazudomodular.com/pj/zcss/docs/claude-md/root **Path:** `CLAUDE.md` # zcss — zudo-css Documentation site teaching CSS best practices, built with Astro 5, MDX, Tailwind CSS v4, and React islands (zudo-doc framework). ## Project Structure ``` src/content/docs/ # MDX articles by category (English) src/content/docs-ja/ # Japanese locale articles src/components/ # CssPreview, PreviewBase, TailwindPreview, etc. src/config/ # Settings, color schemes, sidebars, i18n src/layouts/ # Astro layouts src/pages/ # Astro page routes src/plugins/ # Rehype plugins src/integrations/ # Astro integrations (search, doc-history, sitemap) src/styles/ # Global CSS (Tailwind v4 + design tokens) src/utils/ # Utility functions public/ # Static assets lefthook.yml # Git hooks (pre-commit: format, pre-push: quality checks) .claude/skills/ # Claude Code skills managed in this repo ``` ## Development Package manager: **pnpm** (Node.js >= 20). ```bash pnpm install && pnpm dev # Dev server → http://css-bp.localhost:8811 pnpm build # Production build → dist/ pnpm preview # Preview build pnpm check # Astro type checking ``` ## Tech Stack - **Astro 5** — static site generator with Content Collections - **MDX** — via `@astrojs/mdx`, content in `src/content/docs/` - **Tailwind CSS v4** — via `@tailwindcss/vite` - **React 19** — for interactive islands only (TOC, sidebar, color scheme picker) - **Shiki** — built-in code highlighting with dual-theme support ## Key Directories ``` src/ ├── components/ # Astro + React components │ └── admonitions/ # Note, Tip, Info, Warning, Danger ├── config/ # Settings, color schemes ├── content/ │ ├── docs/ # English MDX content │ └── docs-ja/ # Japanese MDX content ├── hooks/ # React hooks (scroll spy) ├── layouts/ # Astro layouts (doc-layout) ├── pages/ # File-based routing │ ├── docs/[...slug] # English doc routes │ └── ja/docs/[...slug] # Japanese doc routes ├── plugins/ # Rehype plugins ├── integrations/ # Astro integrations (search, history, sitemap) └── styles/ └── global.css # Design tokens (@theme) & Tailwind config ``` ## Article Files - Format: MDX with YAML frontmatter (`sidebar_position`) - Location: `src/content/docs//` - File naming: **kebab-case** (e.g., `centering-techniques.mdx`) - Categories: layout, typography, color, visual, responsive, interactive, methodology, inbox, claude (auto-generated) ### Article Structure Follow this pattern for all articles: 1. `## The Problem` — what goes wrong, common mistakes 2. `## The Solution` — recommended approach with CssPreview demos 3. More sections with demos as needed 4. `## When to Use` — summary of when this technique applies ### CssPreview Demos **Always include CssPreview demos** — they are the most valuable part of each article. Key details: - Renders inside an **iframe** — all CSS is fully isolated - Viewport buttons: Mobile (320px), Tablet (768px), Full (~900-1100px) - No JavaScript — interactions must be CSS-only (`:hover`, `:focus`, `:checked`, etc.) ### CSS Conventions in Demos - Use `hsl()` colors, not hex - Use descriptive BEM-ish class names (e.g., `.card-demo__header`) - Use `font-family: system-ui, sans-serif` for body text - Minimum font size: 0.75rem / 12px for labels - **Template literal indentation**: Always indent `css={}` and `html={}` content by at least 2 spaces. The `dedent()` utility strips common leading whitespace before displaying code in the panel. Content at column 0 produces unindented code display. ## Design Token System Uses a 16-color palette with OKLCH orange accent (`oklch(55.5% 0.163 48.998)`). ### Color Rules - **NEVER** use Tailwind default colors (`bg-gray-500`, `text-blue-600`) - **ALWAYS** use project tokens: `text-fg`, `bg-surface`, `border-muted`, `text-accent`, etc. - Use palette tokens (`p0`–`p15`) only when no semantic token fits ### Color Schemes - Default: **ZCSS Dark** (warm dark theme with orange accents) - Light: **ZCSS Light** (warm light theme with orange accents) - Configured in `src/config/settings.ts` and `src/config/color-schemes.ts` ## Admonitions Available in all MDX files without imports: ``, ``, ``, ``, `` — each accepts optional `title` prop. ## Claude Code Skills This repo manages zcss-specific Claude Code skills in `.claude/skills/`: - **`css-wisdom`** — Topic index of all CSS articles. Symlinked to `~/.claude/skills/css-wisdom` so it's available globally. **When adding or removing articles, run `pnpm generate:css-wisdom` to regenerate the topic index.** Add descriptions for new articles to `.claude/skills/css-wisdom/descriptions.json`. - **`l-writing`** — Writing and formatting rules for MDX articles. **Before writing or editing docs, invoke `/l-writing`.** - **`l-handle-deep-article`** — Guide for converting flat articles into deep articles with sub-pages. Local to this repo. - **`l-demo-component`** — Guide for CssPreview component usage and `defaultOpen` prop conventions. Local to this repo. - **`l-translate`** — Translate English docs to Japanese using the `ja-translator` subagent. Invoke `/l-translate `. - **`b4push`** — Before-push quality checks (type check, build, link check). Invoke `/b4push`. ### Agents - **`ja-translator`** — Subagent for translating MDX docs from English to Japanese. ### Translation Workflow After editing or creating an English doc, translate the Japanese counterpart using `/l-translate`. After editing a Japanese doc, update the English counterpart similarly. ## Safety Rules - `rm -rf`: relative paths only (`./path`), never absolute - No force push, no `--amend` unless explicitly permitted - Temp files go to `__inbox/` (gitignored) --- # Touch Target Sizing > Source: https://takazudomodular.com/pj/zcss/docs/interactive/forms-and-accessibility/touch-target-sizing ## The Problem Small interactive targets are one of the most common accessibility failures on the web. Users with motor impairments, older adults, and anyone on a mobile device struggle to accurately tap small buttons and links. AI agents routinely generate buttons, icon buttons, and navigation links that are far too small — especially icon-only controls that may be only 16-24px in size. WCAG requires a minimum target size, and failing to meet it creates a frustrating, inaccessible experience. ## The Solution All interactive elements must have a minimum touch target size of **44x44 CSS pixels** (WCAG 2.1 Level AAA / best practice) or at minimum **24x24 CSS pixels** (WCAG 2.2 Level AA). The target area includes the element's content, padding, and any additional clickable spacing — so padding is your primary tool for meeting the requirement. ### WCAG Target Size Requirements - **Level AAA (2.5.5)**: At least **44x44px** for all interactive targets. This is the recommended baseline. - **Level AA (2.5.8)**: At least **24x24px**, with at least **24px** of spacing between adjacent targets. ✓ 44px targets (accessible) Save Edit ✕ min-height: 44px · min-width: 44px ✗ 24px targets (too small) Save Edit ✕ height: 24px — hard to tap on mobile `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .section { text-align: center; } .section-title { font-size: 0.8125rem; font-weight: 700; margin: 0 0 1rem; padding: 0.375rem 0.75rem; border-radius: 0.375rem; display: inline-block; } .good-title { background: #f0fdf4; color: #166534; } .bad-title { background: #fef2f2; color: #991b1b; } .btn-row { display: flex; gap: 0.5rem; justify-content: center; align-items: center; flex-wrap: wrap; } .btn { border: none; border-radius: 0.375rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .btn-good { min-height: 44px; min-width: 44px; padding: 0.5rem 1rem; background: #3b82f6; color: white; font-size: 0.875rem; } .icon-btn-good { padding: 0.5rem; font-size: 1rem; } .btn-bad { height: 24px; min-width: 24px; padding: 0 0.5rem; background: #94a3b8; color: white; font-size: 0.6875rem; } .icon-btn-bad { padding: 0 0.375rem; font-size: 0.625rem; } .size-label { font-size: 0.6875rem; color: #94a3b8; margin: 0.75rem 0 0; } `} /> ## Code Examples ### Minimum Button Size ```css .button { min-height: 44px; min-width: 44px; padding: 0.625rem 1.25rem; display: inline-flex; align-items: center; justify-content: center; } ``` ### Icon Button with Adequate Target Icon buttons often have a small visible icon but must maintain a large clickable area: ```css .icon-button { /* Visual size is small, but clickable area is 44x44 */ min-height: 44px; min-width: 44px; padding: 0.625rem; display: inline-flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; border-radius: 0.375rem; } .icon-button svg { width: 1.25rem; height: 1.25rem; } ``` ### Expanding Clickable Area with Pseudo-Elements When you cannot increase the visible size of an element, expand the click target invisibly: ```css .compact-link { position: relative; /* Visual styling stays compact */ font-size: 0.875rem; padding: 0.25rem; } .compact-link::after { content: ""; position: absolute; inset: -0.5rem; /* Expand touch area by 8px in each direction */ /* Ensures minimum 44x44 clickable area */ } ``` ### Navigation Links ```css .nav-link { display: flex; align-items: center; min-height: 44px; padding: 0.5rem 1rem; text-decoration: none; color: var(--color-text); } /* On touch devices, ensure even more generous sizing */ @media (pointer: coarse) { .nav-link { min-height: 48px; padding: 0.75rem 1rem; } } ``` ### Checkbox and Radio Targets Native checkboxes and radios are notoriously small. Use labels and padding: ```css .form-check { display: flex; align-items: center; gap: 0.5rem; min-height: 44px; padding-block: 0.5rem; cursor: pointer; } .form-check input[type="checkbox"], .form-check input[type="radio"] { width: 1.25rem; height: 1.25rem; margin: 0; cursor: pointer; } ``` Wrapping the input in a `` makes the entire label text clickable, greatly increasing the effective touch target. ### Close Button in Tight Spaces ```css .close-button { /* Visually compact but tap-friendly */ display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; min-width: 44px; min-height: 44px; padding: 0; background: none; border: none; cursor: pointer; /* Negative margin to visually align while maintaining target */ margin: -0.375rem; } ``` ### Spacing Between Targets Adjacent targets need adequate spacing to prevent accidental taps: ```css .button-group { display: flex; gap: 0.5rem; /* At least 8px between targets */ } .button-group .button { min-height: 44px; min-width: 44px; } /* Vertical list of tappable items */ .action-list { display: flex; flex-direction: column; gap: 0.25rem; } .action-list__item { min-height: 44px; padding: 0.5rem 1rem; display: flex; align-items: center; } ``` ### Responsive Target Sizing with pointer Query ```css .interactive { min-height: 36px; padding: 0.5rem 1rem; } /* Fine pointer (mouse): slightly smaller targets acceptable */ @media (pointer: fine) { .interactive { min-height: 32px; padding: 0.375rem 0.75rem; } } /* Coarse pointer (touch): larger targets needed */ @media (pointer: coarse) { .interactive { min-height: 48px; padding: 0.75rem 1rem; } } ``` ## Common AI Mistakes - **Making icon buttons too small**: Generating a 24px or 32px icon button without padding to reach the 44px minimum. - **Relying on visual size only**: Assuming the visible size of an element equals its touch target size. Padding and pseudo-elements count toward the target area. - **Ignoring inline links**: Links within paragraphs can be very small touch targets. Adding `padding-block` or increasing `line-height` helps. - **Not testing on actual touch devices**: Target sizes that look fine on a desktop with a mouse become impossible to tap on a phone. - **Crowding targets together**: Placing multiple small buttons or links next to each other without adequate spacing, causing mis-taps. - **Using only `width`/`height` instead of `min-width`/`min-height`**: Fixed dimensions prevent the element from growing when text wraps or content is longer than expected. - **Forgetting `@media (pointer: coarse)`**: Not increasing target sizes on touch devices where accuracy is lower. ## When to Use - **Every interactive element**: Buttons, links, form controls, icon buttons — all must meet the minimum target size. - **Mobile-first design**: Touch targets should be at least 44x44px by default, with the option to reduce slightly for mouse-only contexts. - **Icon-only controls**: Close buttons, menu toggles, action icons — these are the most likely to be undersized. - **Navigation items**: Both horizontal and vertical navigation links. - **Form controls**: Checkboxes, radios, selects, and their labels. ## References - [Understanding SC 2.5.5: Target Size (Enhanced) — W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) - [Understanding SC 2.5.8: Target Size (Minimum) — W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) - [Accessible Target Sizes Cheatsheet — Smashing Magazine](https://www.smashingmagazine.com/2023/04/accessible-tap-target-sizes-rage-taps-clicks/) - [Foundations: Target Sizes — TetraLogical](https://tetralogical.com/blog/2022/12/20/foundations-target-size/) - [All Accessible Touch Target Sizes — LogRocket](https://blog.logrocket.com/ux-design/all-accessible-touch-target-sizes/) --- # Scroll-Driven Animations > Source: https://takazudomodular.com/pj/zcss/docs/interactive/scroll/scroll-driven-animations ## The Problem Scroll-linked effects like progress bars, parallax backgrounds, and element reveal animations have traditionally required JavaScript `scroll` event listeners or Intersection Observer callbacks. These approaches run on the main thread, can cause jank under load, and add complexity. AI agents almost never suggest the CSS-native scroll-driven animations API, even though it provides performant, compositor-thread animations tied to scroll position with zero JavaScript. ## The Solution CSS Scroll-Driven Animations allow you to connect a `@keyframes` animation to scroll progress instead of time. Two timeline types are available: - **`scroll()`** — Tracks the scroll position of a container (0% = top, 100% = fully scrolled). Use for global progress bars and parallax. - **`view()`** — Tracks an element's visibility as it enters and exits the viewport. Use for reveal animations and element-level effects. Both timelines use the standard `@keyframes` syntax and run on the compositor thread when animating `transform` and `opacity`, ensuring smooth 60fps performance. Scroll down Watch elements fade and slide in as they enter the viewport ↓ Card One Fades in on scroll using view() timeline Card Two Each card animates independently Card Three Runs on the compositor thread — smooth 60fps Card Four No JavaScript needed ↑ Scroll back up to replay `} css={` .scroll-container { height: 100%; overflow-y: auto; padding: 1rem; } .intro { text-align: center; padding: 2rem 1rem 3rem; color: #475569; } .intro h2 { margin: 0 0 0.5rem; font-size: 1.25rem; color: #1e293b; } .intro p { margin: 0; font-size: 0.875rem; } .arrow { font-size: 1.5rem; margin-top: 1rem; animation: bounce 1.5s ease infinite; } @keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(8px); } } .reveal-card { color: white; padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; display: flex; flex-direction: column; gap: 0.375rem; animation: card-reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } .reveal-card strong { font-size: 1.125rem; } .reveal-card span { font-size: 0.8125rem; opacity: 0.85; } @keyframes card-reveal { from { opacity: 0; transform: translateY(32px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } .spacer { padding: 3rem 1rem; text-align: center; color: #94a3b8; font-size: 0.875rem; } `} /> ## Code Examples ### Reading Progress Bar A progress bar that fills as the user scrolls the page: ```css .progress-bar { position: fixed; top: 0; left: 0; width: 100%; height: 3px; background: var(--color-primary, #2563eb); transform-origin: left; animation: scale-progress linear; animation-timeline: scroll(); } @keyframes scale-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } ``` ```html ``` ### Fade-In on Scroll (View Timeline) Elements fade in as they enter the viewport: ```css .reveal { animation: fade-in linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } @keyframes fade-in { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } } ``` `animation-range: entry 0% entry 100%` means the animation plays from when the element starts entering the viewport to when it is fully inside. ### Parallax Background ```css .hero { position: relative; height: 80vh; overflow: hidden; } .hero__background { position: absolute; inset: -20% 0; background-image: url("hero.jpg"); background-size: cover; background-position: center; animation: parallax linear; animation-timeline: scroll(); } @keyframes parallax { from { transform: translateY(-10%); } to { transform: translateY(10%); } } ``` The background moves slower than the scroll, creating the parallax depth effect — entirely without JavaScript. ### Sticky Header Shrink on Scroll ```css .header { position: sticky; top: 0; animation: header-shrink linear both; animation-timeline: scroll(); animation-range: 0 200px; } @keyframes header-shrink { from { padding-block: 1.5rem; font-size: 1.5rem; } to { padding-block: 0.5rem; font-size: 1rem; } } ``` `animation-range: 0 200px` limits the animation to the first 200px of scroll, so the header shrinks quickly and then stays at its reduced size. ### Reveal Animation with Stagger Using View Timeline ```css .card { animation: card-reveal linear both; animation-timeline: view(); animation-range: entry 10% entry 90%; } @keyframes card-reveal { from { opacity: 0; transform: translateY(32px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } ``` Each card triggers its own reveal animation as it enters the viewport, independent of other cards. ### Horizontal Scroll Progress for a Specific Container ```css .scrollable-section { overflow-y: auto; max-height: 60vh; scroll-timeline-name: --section-scroll; scroll-timeline-axis: block; } .section-progress { height: 3px; background: var(--color-primary, #2563eb); transform-origin: left; animation: scale-progress linear; animation-timeline: --section-scroll; } @keyframes scale-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } ``` Named scroll timelines (`scroll-timeline-name`) let you link an animation to a specific scroll container, not just the document root. ### View Timeline Ranges Explained The `animation-range` property accepts named ranges for view timelines: ```css /* Full visibility lifecycle */ .element { animation-timeline: view(); } /* entry: element enters the viewport */ .entry-anim { animation-range: entry 0% entry 100%; } /* exit: element leaves the viewport */ .exit-anim { animation-range: exit 0% exit 100%; } /* contain: element is fully contained in viewport */ .contain-anim { animation-range: contain 0% contain 100%; } /* cover: from first pixel entering to last pixel leaving */ .cover-anim { animation-range: cover 0% cover 100%; } ``` ## Common AI Mistakes - **Using JavaScript for scroll-linked animations**: Reaching for `addEventListener('scroll')` or Intersection Observer when CSS `animation-timeline` handles the use case natively. - **Not knowing the API exists**: This is the most common mistake. AI agents generate JavaScript for progress bars and reveal animations that CSS can handle alone. - **Forgetting `animation-range`**: Without `animation-range`, a view timeline animation spans the entire visibility lifecycle. Most use cases need a specific range like `entry` or `contain`. - **Animating expensive properties**: Using scroll-driven animations on layout-triggering properties like `height` or `margin`. Stick to `transform` and `opacity` for compositor-thread performance. - **Not providing a fallback**: Scroll-driven animations are not supported in all browsers (Firefox has limited support). Always ensure the content is visible and usable without the animation. - **Using `scroll()` when `view()` is appropriate**: `scroll()` tracks the scroll container's position globally; `view()` tracks an individual element's visibility. Element reveals need `view()`, not `scroll()`. ## When to Use - **Reading progress bars**: A `scroll()` timeline tracking document scroll. - **Element reveal animations**: A `view()` timeline triggering fade-in, slide-up, or scale animations as elements enter the viewport. - **Parallax effects**: A `scroll()` timeline moving background layers at different speeds. - **Header transformations**: Shrinking/restyling a sticky header based on scroll depth. - **Not for complex interaction logic**: Scroll-driven animations are declarative and respond to scroll position. For logic like "animate only once" or "trigger after a delay," JavaScript or Intersection Observer may still be needed. - **Always as progressive enhancement**: Ensure content works without the animation. Use `@supports (animation-timeline: scroll())` for feature detection if needed. ## References - [CSS Scroll-Driven Animations — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations) - [Scroll-Driven Animation Timelines — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timelines) - [animation-timeline — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-timeline) - [Animate Elements on Scroll — Chrome for Developers](https://developer.chrome.com/docs/css-ui/scroll-driven-animations) - [Scroll-Driven Animations Guide — design.dev](https://design.dev/guides/scroll-timeline/) - [Introduction to CSS Scroll-Driven Animations — Smashing Magazine](https://www.smashingmagazine.com/2024/12/introduction-css-scroll-driven-animations/) --- # :is() and :where() Selectors > Source: https://takazudomodular.com/pj/zcss/docs/interactive/selectors/is-where-selectors ## The Problem CSS selectors that share the same declarations require writing out every combination individually, leading to verbose and repetitive rule sets. Selector lists with different specificity levels make it difficult to create easily overridable base styles. When building resets, defaults, or library CSS, specificity conflicts with consumer styles are a constant battle. AI agents typically generate repetitive selectors and rarely use `:is()` or `:where()` to manage specificity intentionally. ## The Solution `:is()` and `:where()` are functional pseudo-class selectors that accept a selector list and match any element that matches at least one selector in that list. They reduce repetition by grouping selectors. The critical difference is specificity: - **`:is()`** takes on the specificity of its most specific argument - **`:where()`** always has zero specificity `(0, 0, 0)` This makes `:where()` ideal for default/base styles that should be easily overridable, and `:is()` for grouping selectors while preserving specificity. ## Code Examples ### Reducing Selector Repetition with `:is()` ```css /* Without :is() — verbose and repetitive */ article h1, article h2, article h3, section h1, section h2, section h3, aside h1, aside h2, aside h3 { line-height: 1.2; } /* With :is() — concise */ :is(article, section, aside) :is(h1, h2, h3) { line-height: 1.2; } ``` ### Zero-Specificity Defaults with `:where()` Create base styles that any single class can easily override. ```css /* Base styles with zero specificity — trivially overridable */ :where(h1, h2, h3, h4, h5, h6) { margin-block: 0; font-weight: 700; } :where(ul, ol) { padding-left: 1.5rem; } :where(a) { color: #2563eb; text-decoration: underline; } /* Any class override wins without specificity battles */ .nav-link { color: inherit; text-decoration: none; } ``` ### Building an Overridable Reset ```css /* A reset that never fights with author styles */ :where(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; } :where(html) { line-height: 1.5; -webkit-text-size-adjust: 100%; } :where(img, picture, video, canvas, svg) { display: block; max-width: 100%; } :where(input, button, textarea, select) { font: inherit; } ``` ### Combining `:is()` and `:where()` Strategically Use `:is()` for the parts where you want specificity to contribute, and `:where()` for the parts you want to be zero. ```css /* The .article class contributes specificity (0,1,0), but the element selectors inside :where() add nothing */ .article :where(p, li, blockquote) { line-height: 1.8; max-width: 65ch; } /* Override with just a class — no specificity fight */ .compact-text { line-height: 1.4; } ``` ### Forgiving Selector Lists Both `:is()` and `:where()` use a forgiving selector list. An invalid selector in the list does not invalidate the entire rule. ```css /* If :未来的-selector is invalid, the rest still works */ :is(.card, .panel, :未来的-selector) { border-radius: 8px; } /* Without :is(), one invalid selector breaks the entire rule */ /* .card, .panel, :未来的-selector { border-radius: 8px; } — entire rule is dropped */ ``` ### Simplifying Nested Selectors ```css /* Complex nesting without :is() */ .sidebar nav ul li a, .sidebar nav ol li a { color: #374151; text-decoration: none; } /* Simplified with :is() */ .sidebar nav :is(ul, ol) li a { color: #374151; text-decoration: none; } ``` ### Specificity Comparison ```css /* :is() specificity = highest argument */ :is(.class, #id) p { /* Specificity: (1, 0, 1) because #id is the highest */ color: blue; } /* :where() specificity = always zero */ :where(.class, #id) p { /* Specificity: (0, 0, 1) — only the p contributes */ color: blue; } /* A simple class wins over :where(#id) */ .text { /* Specificity: (0, 1, 0) — wins over :where(#id) p's (0, 0, 1) */ color: red; } ``` ### Library/Design System Pattern ```css /* Design system default — zero specificity via :where() */ :where(.ds-button) { padding: 0.5rem 1rem; border: 1px solid #d1d5db; border-radius: 4px; background: white; cursor: pointer; } :where(.ds-button.primary) { background: #2563eb; color: white; border-color: #2563eb; } /* Consumer can override with just a class — no !important needed */ .my-button { background: #16a34a; border-color: #16a34a; } ``` ## Browser Support - Chrome 88+ - Firefox 78+ - Safari 14+ - Edge 88+ Both `:is()` and `:where()` have global support exceeding 96%. They are safe for production use. ## Common AI Mistakes - Not knowing `:where()` exists and generating verbose selector lists that create specificity conflicts - Using `:is()` when `:where()` would be more appropriate (e.g., for base/reset styles that should be easily overridable) - Writing out every selector combination manually instead of grouping with `:is()` - Not leveraging `:where()` for library/design system CSS to avoid specificity wars with consumers - Confusing the specificity behavior — thinking `:is()` has zero specificity like `:where()` - Not realizing that `:is()` and `:where()` use forgiving selector lists (invalid selectors don't break the rule) - Using `!important` to override base styles when `:where()` would have made them trivially overridable ## When to Use - **`:is()`**: Group selectors to reduce repetition while preserving specificity - **`:where()`**: Create zero-specificity base styles, resets, and design system defaults - **Both**: Build forgiving selector lists that handle unknown or future selectors gracefully - **`:where()` for library CSS**: Ensure consumer styles can always override without `!important` - **Combined**: Use `:is()` for the specificity-contributing part and `:where()` for the zero-specificity part of a selector ## Live Previews Article Heading (h1) Sub-heading (h2) Regular paragraph text. Section heading (h3) More paragraph text. Heading Outside Article This heading is not styled — :is(article, section) limits scope `} css={` * { margin: 0; } article, .outside { font-family: system-ui, sans-serif; padding: 1.25rem; border-radius: 12px; } article { background: #f0fdf4; border: 1px solid #86efac; margin-bottom: 1rem; } :is(article, section) :is(h1, h2, h3) { color: #166534; line-height: 1.2; margin-bottom: 0.5rem; padding-bottom: 0.25rem; border-bottom: 2px solid #bbf7d0; } article p, .outside p { color: #64748b; line-height: 1.6; margin-bottom: 0.75rem; font-size: 0.9rem; } .outside { background: #f8fafc; border: 1px dashed #d1d5db; } .outside h2 { color: #6b7280; font-size: 1.1rem; margin-bottom: 0.5rem; } `} /> Default link (styled by :where) Custom link (single class overrides :where) :where(a) has zero specificity (0,0,0), so even a single .my-link class (0,1,0) wins without any !important `} css={` :where(a) { color: #2563eb; text-decoration: underline; font-weight: 600; } .my-link { color: #dc2626; text-decoration: none; background: #fef2f2; padding: 0.25rem 0.75rem; border-radius: 6px; border: 1px solid #fca5a5; } .demo { font-family: system-ui, sans-serif; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; } `} /> ## References - [:is() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:is) - [:where() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:where) - [:is() pseudo-class - Can I Use](https://caniuse.com/css-matches-pseudo) - [:where() pseudo-class - Can I Use](https://caniuse.com/mdn-css_selectors_where) - [CSS :where() Selector: The Zero-Specificity Superpower - McNeece](https://www.mcneece.com/2025/03/css-where-selector-the-zero-specificity-superpower/) --- # Transition Best Practices > Source: https://takazudomodular.com/pj/zcss/docs/interactive/states-and-transitions/transition-best-practices ## The Problem CSS transitions add polish and help users understand state changes. However, AI agents consistently make the same mistakes: transitioning expensive properties (like `width`, `height`, and `margin`) that trigger layout recalculations, using generic `transition: all 0.3s ease` declarations, and never considering the `transition-behavior: allow-discrete` property for transitioning `display` or other discrete properties. The result is janky animations, poor performance, and missed opportunities for smooth UI transitions. ## The Solution Transition only **cheap properties** (`transform`, `opacity`) whenever possible, use specific property lists instead of `all`, choose appropriate easing curves, and leverage `transition-behavior: allow-discrete` with `@starting-style` for entry/exit animations. ### Performance Tiers 1. **Cheap (compositor-only)**: `transform`, `opacity` — run on the GPU compositor thread, no layout or paint. 2. **Moderate (paint-only)**: `background-color`, `color`, `box-shadow` — trigger repaint but not layout. 3. **Expensive (layout-triggering)**: `width`, `height`, `margin`, `padding`, `top`, `left` — trigger full layout recalculation. ✓ transform + opacity Compositor-only (smooth) Hover me ✗ width + box-shadow Layout-triggering (janky) Hover me `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .demo-col { text-align: center; } .label { font-size: 0.75rem; color: #94a3b8; margin: 0.75rem 0 0; font-style: italic; } .card { padding: 1.5rem 1rem; border-radius: 0.5rem; text-align: center; font-size: 0.8125rem; display: flex; flex-direction: column; align-items: center; gap: 0.375rem; } .card-icon { font-size: 1.5rem; margin-bottom: 0.25rem; } .card-sub { font-size: 0.6875rem; opacity: 0.7; } .card-good { background: #f0fdf4; border: 2px solid #22c55e; color: #166534; transition: transform 0.25s ease, opacity 0.25s ease; } .card-good:hover { transform: translateY(-6px) scale(1.02); opacity: 0.9; } .card-bad { background: #fef2f2; border: 2px solid #ef4444; color: #991b1b; transition: box-shadow 0.6s ease, padding 0.6s ease; } .card-bad:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.3); padding: 2rem 1.5rem; } `} /> ## Code Examples ### Transitioning the Right Properties ```css /* Good: transform and opacity are cheap */ .card { transition: transform 0.2s ease, opacity 0.2s ease; } @media (hover: hover) { .card:hover { transform: translateY(-4px); opacity: 0.95; } } /* Bad: animating width triggers layout recalculation */ .card-bad { transition: width 0.3s ease, height 0.3s ease; } ``` Replace expensive property transitions with `transform` equivalents: ```css /* Instead of transitioning width */ .expandable { transform: scaleX(0); transform-origin: left; transition: transform 0.3s ease; } .expandable.open { transform: scaleX(1); } /* Instead of transitioning top/left */ .slide-in { transform: translateX(-100%); transition: transform 0.3s ease; } .slide-in.visible { transform: translateX(0); } ``` ### Be Specific — Avoid transition: all ```css /* Bad: transitions every property change, including unintended ones */ .element { transition: all 0.3s ease; } /* Good: only transition what you intend */ .element { transition: background-color 0.15s ease, transform 0.2s ease; } ``` ### Choosing Easing Functions ```css /* Default ease — good general purpose */ .fade { transition: opacity 0.2s ease; } /* ease-out — element arriving (enters fast, decelerates) */ .slide-enter { transition: transform 0.3s ease-out; } /* ease-in — element leaving (starts slow, accelerates) */ .slide-exit { transition: transform 0.3s ease-in; } /* ease-in-out — continuous motion (both ends decelerate) */ .move { transition: transform 0.4s ease-in-out; } /* Custom cubic-bezier for a snappy, natural feel */ .bounce { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } /* Custom ease-out for UI interactions */ .interact { transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); } ``` ### Duration Guidelines ```css /* Micro-interactions: 100-200ms */ .button { transition: background-color 0.15s ease; } /* State changes: 200-300ms */ .panel { transition: transform 0.25s ease-out; } /* Complex or large animations: 300-500ms */ .modal-backdrop { transition: opacity 0.35s ease; } ``` ### Transitioning display with transition-behavior: allow-discrete Traditionally, `display: none` cannot be transitioned because it is a discrete property. The `transition-behavior: allow-discrete` property, combined with `@starting-style`, changes this. ```css .tooltip { /* Final visible state */ opacity: 1; transform: translateY(0); display: block; /* Transition including discrete display change */ transition: opacity 0.2s ease, transform 0.2s ease, display 0.2s allow-discrete; /* Starting state for entry animation */ @starting-style { opacity: 0; transform: translateY(-4px); } } .tooltip[hidden] { /* Exit state */ opacity: 0; transform: translateY(-4px); display: none; } ``` ### Popover Entry/Exit Animation ```css [popover] { /* Final open state */ opacity: 1; transform: translateY(0) scale(1); transition: opacity 0.25s ease, transform 0.25s ease, overlay 0.25s allow-discrete, display 0.25s allow-discrete; /* Entry animation starting state */ @starting-style { opacity: 0; transform: translateY(8px) scale(0.96); } } /* Exit state */ [popover]:not(:popover-open) { opacity: 0; transform: translateY(8px) scale(0.96); } ``` ### Dialog with Backdrop Transition ```css dialog { opacity: 1; transform: translateY(0); transition: opacity 0.3s ease, transform 0.3s ease, overlay 0.3s allow-discrete, display 0.3s allow-discrete; @starting-style { opacity: 0; transform: translateY(16px); } } dialog:not([open]) { opacity: 0; transform: translateY(16px); } dialog::backdrop { background: rgb(0 0 0 / 0.5); opacity: 1; transition: opacity 0.3s ease, display 0.3s allow-discrete; @starting-style { opacity: 0; } } ``` ### Staggered Transitions ```css .list-item { opacity: 0; transform: translateY(8px); transition: opacity 0.3s ease, transform 0.3s ease; } .list-item.visible { opacity: 1; transform: translateY(0); } .list-item:nth-child(1) { transition-delay: 0ms; } .list-item:nth-child(2) { transition-delay: 50ms; } .list-item:nth-child(3) { transition-delay: 100ms; } .list-item:nth-child(4) { transition-delay: 150ms; } ``` ## Common AI Mistakes - **Using `transition: all`**: Transitions every property, including layout-triggering ones, causing performance issues and unintended visual changes. - **Animating expensive properties**: Transitioning `width`, `height`, `margin`, `top`, `left` instead of using `transform` (translate, scale). - **Using `linear` or `ease` for everything**: Not matching the easing to the interaction type. Entering elements should use `ease-out`; exiting elements should use `ease-in`. - **Too-long durations**: Using `0.5s` or longer for simple state changes. Micro-interactions should be `100-200ms`. - **Not knowing about `transition-behavior: allow-discrete`**: Using JavaScript to toggle classes with delays instead of transitioning `display` with `allow-discrete` and `@starting-style`. - **Forgetting `@starting-style`**: Using `transition-behavior: allow-discrete` without `@starting-style`, so the entry animation has no starting state to transition from. - **Transitioning on page load**: Not scoping transitions so they fire when the page first renders, causing distracting animations. ## When to Use - **State changes**: Hover, focus, active, open/closed states of interactive elements. - **`transform` and `opacity`**: Always prefer these for motion. They run on the compositor thread and never cause jank. - **`transition-behavior: allow-discrete`**: Animating `display: none` to `display: block` for tooltips, popovers, dialogs, and dropdown menus. - **`@starting-style`**: Defining the initial state of an element entering the page or becoming visible for the first time. - **Not for complex sequences**: Use CSS `@keyframes` animations for multi-step sequences. Transitions handle two-state changes. ## References - [Using CSS Transitions — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using) - [transition-behavior — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-behavior) - [An Interactive Guide to CSS Transitions — Josh W. Comeau](https://www.joshwcomeau.com/animation/css-transitions/) - [Ten Tips for Better CSS Transitions and Animations — Josh Collinsworth](https://joshcollinsworth.com/blog/great-transitions) - [Transitioning Top-Layer Entries and the Display Property — Smashing Magazine](https://www.smashingmagazine.com/2025/01/transitioning-top-layer-entries-display-property-css/) - [Four New CSS Features for Smooth Entry and Exit Animations — Chrome for Developers](https://developer.chrome.com/blog/entry-exit-animations/) --- # CSS Grid Patterns > Source: https://takazudomodular.com/pj/zcss/docs/layout/flexbox-and-grid/grid-patterns ## The Problem CSS Grid is the most powerful layout system in CSS, but AI agents often underutilize it or apply it incorrectly. Common mistakes include confusing `auto-fill` with `auto-fit`, using fixed column counts instead of responsive patterns, avoiding `grid-template-areas` when it would dramatically improve readability, and reaching for JavaScript or media queries when `minmax()` handles responsiveness natively. ## The Solution CSS Grid is a two-dimensional layout system. Use it for page-level layouts, responsive card grids, and any scenario where items need to align across both rows and columns. The combination of `auto-fill`/`auto-fit` with `minmax()` enables responsive layouts without a single media query. ## Code Examples ### auto-fill vs auto-fit The difference matters when there are fewer items than the grid can fit. ```css /* auto-fill: keeps empty tracks, preserving the grid structure */ .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; } /* auto-fit: collapses empty tracks, items stretch to fill space */ .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } ``` When 3 items are in a container wide enough for 5 columns: - `auto-fill` creates 5 columns, 2 remain empty. Items stay at 200px-ish width. - `auto-fit` collapses the 2 empty columns. Items stretch to fill the full width. **Use `auto-fit`** for card grids, gallery layouts, and most UI patterns where items should expand to use available space. **Use `auto-fill`** when you want to maintain consistent column widths regardless of item count, such as form fields or data entry layouts. auto-fill (empty tracks preserved): 1 2 3 auto-fit (items stretch to fill): 1 2 3 `} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; color: #334155; margin-bottom: 8px; } .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; margin-bottom: 20px; } .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 10px; } .item { background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; } .item.fit { background: #22c55e; }`} /> ### Responsive Card Grid (No Media Queries) ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 1.5rem; } ``` ```html Card 1 Card 2 Card 3 Card 4 ``` Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={`.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr)); gap: 12px; padding: 8px; } .card { background: #8b5cf6; color: #fff; padding: 20px 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; }`} /> ### Named Grid Areas Grid areas make complex layouts readable and maintainable. ```css .page-layout { display: grid; grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 250px 1fr; grid-template-rows: auto 1fr auto; min-height: 100vh; } .header { grid-area: header; } .sidebar { grid-area: sidebar; } .main { grid-area: main; } .footer { grid-area: footer; } /* Responsive: stack on narrow viewports */ @media (max-width: 768px) { .page-layout { grid-template-areas: "header" "main" "sidebar" "footer"; grid-template-columns: 1fr; } } ``` ```html Header Sidebar Main Content Footer ``` Header Sidebar Main Content Footer `} css={`.page-layout { display: grid; grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 120px 1fr; grid-template-rows: auto 1fr auto; min-height: 280px; gap: 8px; padding: 8px; font-family: system-ui, sans-serif; font-size: 16px; } .header { grid-area: header; background: #3b82f6; color: #fff; padding: 12px 16px; border-radius: 8px; } .sidebar { grid-area: sidebar; background: #f59e0b; color: #fff; padding: 12px 16px; border-radius: 8px; } .main { grid-area: main; background: #22c55e; color: #fff; padding: 16px; border-radius: 8px; } .footer { grid-area: footer; background: #8b5cf6; color: #fff; padding: 12px 16px; border-radius: 8px; }`} height={320} /> ### grid-template Shorthand for Complex Layouts The `grid-template` shorthand combines `grid-template-rows`, `grid-template-columns`, and `grid-template-areas` in one declaration. ```css .dashboard { display: grid; grid-template: "nav nav nav" 60px "side main aside" 1fr "footer footer footer" 80px / 200px 1fr 250px; min-height: 100vh; gap: 1rem; } ``` ### Spanning Multiple Rows or Columns ```css .featured-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto; gap: 1rem; } .featured-item { grid-column: span 2; grid-row: span 2; } ``` ### Dense Packing (Filling Gaps) ```css .masonry-like { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-auto-flow: dense; gap: 1rem; } ``` The `dense` keyword tells the browser to backfill empty cells with smaller items that appear later in the DOM, creating a tighter layout. ## Common AI Mistakes - **Using `auto-fill` when `auto-fit` is intended.** Most UI patterns want items to stretch and fill available space (`auto-fit`). AI agents often pick `auto-fill`, which leaves empty tracks. - **Writing `minmax(300px, 1fr)` without a `min()` guard.** On viewports narrower than 300px, this causes horizontal overflow. Always use `minmax(min(100%, 300px), 1fr)`. - **Avoiding `grid-template-areas`.** AI agents tend to use row/column numbers for placement, producing unreadable code. Named areas are self-documenting and easier to refactor. - **Setting explicit column counts instead of using `repeat(auto-fit, ...)`.** A fixed `grid-template-columns: repeat(3, 1fr)` requires media queries to be responsive. `auto-fit` with `minmax()` handles this automatically. - **Using flexbox for a grid of cards.** When cards need to align in both rows and columns with consistent sizing, CSS Grid is the correct tool. - **Forgetting `gap` and using margin on grid items instead.** Grid has built-in `gap` support. Margins on grid items add space outside the grid tracks, breaking alignment. - **Not setting `min-height: 100vh` on full-page grid layouts.** Without it, the grid only takes up the height of its content, and areas like the footer will not reach the bottom. ## When to Use ### CSS Grid is ideal for - Two-dimensional layouts (rows and columns simultaneously) - Page-level layout with header, sidebar, main, footer - Responsive card grids with auto-fit/auto-fill - Layouts where items span multiple rows or columns - Any layout that benefits from named areas for readability ### Use Flexbox instead when - You only need a one-dimensional layout (a single row or column) - Items have unknown widths and need to distribute space (navigation, toolbars) - You want items to wrap but do not need column alignment across rows ## Tailwind CSS Tailwind provides grid utilities with responsive breakpoint prefixes. While CSS Grid's `auto-fit`/`auto-fill` with `minmax()` isn't available as a utility, Tailwind's responsive prefixes (`sm:`, `md:`, `lg:`) offer an explicit breakpoint-based alternative. ### Responsive Card Grid Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ### Column Spanning Spans 2 columns 1 col 1 col 1 col 1 col `} /> ## References - [A Complete Guide to CSS Grid - CSS-Tricks](https://css-tricks.com/snippets/css/complete-guide-grid/) - [CSS Grid Layout - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout) - [Auto-Sizing Columns: auto-fill vs auto-fit - CSS-Tricks](https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/) - [A Deep Dive Into CSS Grid minmax() - Ahmad Shadeed](https://ishadeed.com/article/css-grid-minmax/) - [Learn CSS Grid - web.dev](https://web.dev/learn/css/grid) --- # Positioning Guide > Source: https://takazudomodular.com/pj/zcss/docs/layout/positioning/positioning-guide ## The Problem CSS positioning is frequently misused by AI agents. The most common errors include using `position: absolute` for layout tasks that should use flexbox or grid, forgetting to set `position: relative` on the containing element, using `position: fixed` without considering mobile viewport issues, and failing to understand that `position: sticky` requires a scroll container and a threshold value. ## The Solution CSS `position` removes or adjusts elements relative to their normal document flow. Each value serves a specific purpose, and choosing the wrong one creates fragile layouts that break across screen sizes. ## Code Examples ### static (Default) Elements are in the normal document flow. The `top`, `right`, `bottom`, `left`, and `z-index` properties have no effect. ```css .element { position: static; /* default, rarely needs to be written explicitly */ } ``` ### relative The element stays in the normal flow but can be offset from its original position. It creates a containing block for absolutely positioned children. ```css .parent { position: relative; /* Establishes containing block for children */ } .badge { position: relative; top: -4px; /* Shifts up 4px from its normal position */ } ``` Use `position: relative` primarily to establish a containing block for `position: absolute` children, or for minor visual offsets without affecting layout. ### absolute The element is removed from the document flow and positioned relative to its nearest positioned ancestor (any ancestor with `position` other than `static`). ```css .card { position: relative; /* Containing block */ } .card-badge { position: absolute; top: -8px; right: -8px; } ``` ```html New Card content ``` New Card content goes here. The badge is absolutely positioned relative to this card. `} css={`.card { position: relative; background: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; padding: 24px; margin: 16px; font-family: system-ui, sans-serif; font-size: 16px; } .badge { position: absolute; top: -10px; right: -10px; background: #ef4444; color: #fff; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; } .card-text { margin: 0; color: #334155; }`} /> ### Absolute Centering with inset The `inset` shorthand replaces `top`, `right`, `bottom`, `left`. ```css .overlay { position: absolute; inset: 0; /* top: 0; right: 0; bottom: 0; left: 0 */ } .modal { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; } ``` ### fixed The element is removed from the document flow and positioned relative to the viewport. It does not move when the page scrolls. ```css .sticky-header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; } /* Prevent content from being hidden behind the fixed header */ body { padding-top: 60px; /* Match the header height */ } ``` This area represents a page with scrollable content. Notice the floating button in the bottom-right corner — it stays in place regardless of scroll position. + `} css={`.page { position: relative; min-height: 200px; background: #f1f5f9; padding: 20px; font-family: system-ui, sans-serif; font-size: 16px; border-radius: 8px; } .content { color: #334155; } .content p { margin: 0; line-height: 1.6; } .fab { position: absolute; bottom: 16px; right: 16px; width: 48px; height: 48px; border-radius: 50%; background: #3b82f6; color: #fff; border: none; font-size: 24px; cursor: pointer; box-shadow: 0 4px 12px rgba(59,130,246,0.4); display: flex; align-items: center; justify-content: center; }`} /> ### sticky The element acts as `relative` until it crosses a scroll threshold, then behaves as `fixed` within its containing block. ```css .table-header { position: sticky; top: 0; background: white; z-index: 10; } ``` ```html Name Value ``` Sticky Header — scroll down Section 1 content Section 2 content Section 3 content Section 4 content Section 5 content Section 6 content Section 7 content `} css={`.scroll-container { height: 280px; overflow-y: auto; border-radius: 8px; border: 2px solid #e2e8f0; font-family: system-ui, sans-serif; font-size: 16px; } .sticky-header { position: sticky; top: 0; background: #3b82f6; color: #fff; padding: 12px 20px; font-weight: 600; z-index: 10; } .content-block { padding: 20px; border-bottom: 1px solid #e2e8f0; color: #334155; min-height: 80px; }`} height={300} /> ### Sticky Sidebar ```css .page { display: flex; align-items: flex-start; /* Critical: prevents sidebar from stretching */ } .sidebar { position: sticky; top: 1rem; width: 250px; flex-shrink: 0; } .main-content { flex: 1; min-width: 0; } ``` ```html Sticky sidebar Scrollable content ``` ## Why position: sticky Fails Sticky positioning fails silently in several situations: ### No threshold set ```css /* Broken: no top, bottom, left, or right value */ .sticky-broken { position: sticky; } /* Fixed: threshold tells the browser when to stick */ .sticky-working { position: sticky; top: 0; } ``` ### Parent has overflow: hidden or overflow: auto ```css /* Broken: overflow on parent prevents sticking */ .parent { overflow: hidden; /* or overflow: auto, overflow: scroll */ } .parent .sticky-child { position: sticky; top: 0; /* This will not stick */ } ``` ### Parent has no scrollable height The sticky element sticks within its parent. If the parent is only as tall as the sticky element itself, there is nothing to scroll within. ## Common AI Mistakes - **Using `position: absolute` for layout.** Absolute positioning removes elements from the flow, making layouts fragile. Use flexbox or grid for layout, and reserve absolute positioning for overlays, badges, and decorative elements. - **Forgetting `position: relative` on the parent.** Without a positioned ancestor, absolutely positioned elements are positioned relative to the initial containing block (typically the viewport), not the intended parent. - **Using `position: fixed` for sticky headers on mobile.** Fixed positioning on mobile can cause issues with virtual keyboards, address bar resizing, and scroll performance. Test on actual mobile devices. - **Not setting a threshold on `position: sticky`.** A sticky element requires at least one of `top`, `right`, `bottom`, or `left` to be set to a non-auto value. Without a threshold, it will not stick. - **Using `z-index` without understanding stacking contexts.** Setting `z-index: 9999` does not guarantee an element appears on top. It only controls stacking within the same stacking context. See the Stacking Context guide for details. - **Using `top: 0; right: 0; bottom: 0; left: 0` instead of `inset: 0`.** The `inset` shorthand is more concise and readable. - **Using absolute positioning with percentage widths for responsive design.** Absolutely positioned elements are sized relative to their containing block, not the viewport. This breaks when the containing block changes size unexpectedly. ## When to Use ### relative - Establishing a containing block for absolutely positioned children - Minor visual offsets without affecting layout - Creating a stacking context (when combined with z-index) ### absolute - Badges, labels, close buttons on cards or modals - Overlay elements that sit on top of a positioned container - Tooltip arrows and popovers - Decorative elements that should not affect document flow ### fixed - Navigation bars that remain visible during scrolling - "Back to top" buttons - Cookie consent banners - Floating action buttons ### sticky - Table headers that stay visible while scrolling the table - Section headers in long scrollable lists - Sidebars that follow the user within their container - "Add to cart" bars on product pages ## References - [position - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/position) - [CSS Position Property - web.dev](https://web.dev/learn/css/layout#positioning) - [position: sticky - CSS-Tricks](https://css-tricks.com/position-sticky-2/) - [CSS Positioning Explained - InterviewBuzz](https://interviewbuzz.com/blog/css-positioning-explained-master-relative-absolute-fixed-sticky) --- # aspect-ratio > Source: https://takazudomodular.com/pj/zcss/docs/layout/sizing/aspect-ratio ## The Problem Maintaining aspect ratios for images, videos, embedded content, and card layouts is a common requirement. For years, the only reliable technique was the "padding-top hack" — using percentage-based padding to create a proportional box. AI agents often still generate the old hack or, worse, use fixed pixel dimensions that break on different screen sizes. The modern `aspect-ratio` property solves this in a single line with no extra markup. ## The Solution The `aspect-ratio` CSS property sets a preferred aspect ratio for an element. The browser adjusts the element's dimensions to maintain this ratio, and content that exceeds the ratio can overflow or be handled with `overflow` or `object-fit`. ```css .element { aspect-ratio: 16 / 9; } ``` This is all that is needed. No wrapper divs, no absolute positioning, no percentage padding calculations. ## Code Examples ### Basic Image with Aspect Ratio ```css .thumbnail { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 0.5rem; } ``` ```html ``` The image fills its container width and maintains a 16:9 ratio. `object-fit: cover` ensures the image fills the box without distortion, cropping if necessary. 16 / 9 4 / 3 1 / 1 `} css={`.container { display: flex; gap: 12px; padding: 8px; font-family: system-ui, sans-serif; } .thumbnail { flex: 1; aspect-ratio: 16 / 9; background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 8px; display: flex; align-items: center; justify-content: center; } .ratio-4-3 { aspect-ratio: 4 / 3; background: linear-gradient(135deg, #22c55e, #14b8a6); } .ratio-1 { aspect-ratio: 1; background: linear-gradient(135deg, #f59e0b, #ef4444); } .label { color: #fff; font-size: 16px; font-weight: 600; }`} /> ### Responsive Video Container ```css .video-wrapper { width: 100%; aspect-ratio: 16 / 9; } .video-wrapper iframe { width: 100%; height: 100%; border: 0; } ``` ```html ``` ### Square Card Grid ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 1rem; } .card { aspect-ratio: 1; /* Square */ overflow: hidden; border-radius: 0.5rem; } .card img { width: 100%; height: 100%; object-fit: cover; } ``` ```html ``` Card Title Card Title Card Title `} css={`.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 8px; font-family: system-ui, sans-serif; } .card { border-radius: 8px; overflow: hidden; border: 2px solid #e2e8f0; } .card-img { aspect-ratio: 16 / 9; background: linear-gradient(135deg, #3b82f6, #6366f1); } .card-img.img2 { background: linear-gradient(135deg, #22c55e, #14b8a6); } .card-img.img3 { background: linear-gradient(135deg, #f59e0b, #ef4444); } .card-body { padding: 12px; font-size: 14px; font-weight: 600; color: #334155; }`} /> ### Aspect Ratio with Fallback Content When content might overflow the aspect ratio box: ```css .card-fixed { aspect-ratio: 4 / 3; overflow: hidden; padding: 1rem; } ``` If the content is taller than the 4:3 ratio allows, it is clipped. For scrollable overflow, use `overflow: auto` instead. ### The Old Padding-Top Hack (For Reference) This is the legacy technique that AI agents should stop generating: ```css /* OLD: padding-top hack — do not use in new code */ .video-wrapper-old { position: relative; width: 100%; padding-top: 56.25%; /* 9/16 = 0.5625 */ height: 0; } .video-wrapper-old iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } ``` ```css /* NEW: aspect-ratio — use this instead */ .video-wrapper-new { width: 100%; aspect-ratio: 16 / 9; } .video-wrapper-new iframe { width: 100%; height: 100%; } ``` The modern version is shorter, more readable, requires no wrapper trickery, and does not misuse the `padding` property. ### Preventing Layout Shift on Images Setting `aspect-ratio` on images reserves space before the image loads, preventing Cumulative Layout Shift (CLS): ```css img { aspect-ratio: attr(width) / attr(height); width: 100%; height: auto; } ``` In practice, browsers already compute aspect ratio from the `width` and `height` HTML attributes. Always include both attributes on `` tags: ```html ``` ### Circle Avatar ```css .avatar { width: 3rem; aspect-ratio: 1; border-radius: 50%; object-fit: cover; } ``` ```html ``` ## Common AI Mistakes - **Still generating the padding-top hack.** The `aspect-ratio` property has had full browser support since 2021 (96%+ global coverage). The padding hack is no longer needed for any modern browser target. - **Forgetting `object-fit` on images.** Setting `aspect-ratio` on an `` without `object-fit: cover` (or `contain`) causes the image to distort if its natural ratio differs from the specified ratio. - **Using fixed pixel dimensions instead of aspect-ratio.** AI agents often set `width: 640px; height: 360px` for a 16:9 element. This breaks on smaller screens. Use `width: 100%; aspect-ratio: 16 / 9` for responsive behavior. - **Not including `width` and `height` attributes on `` tags.** These attributes allow the browser to reserve the correct space before the image loads, preventing layout shift. This is a Core Web Vitals requirement. - **Setting both `width` and `height` in CSS alongside `aspect-ratio`.** If both dimensions are explicitly set, `aspect-ratio` has no effect. Set one dimension (usually `width`) and let `aspect-ratio` calculate the other. - **Using `aspect-ratio` on flex/grid items without understanding how it interacts with stretching.** In a flex container with `align-items: stretch` (the default), the item's height is determined by the container, which overrides `aspect-ratio`. Set `align-items: flex-start` or `align-self: start` on the item. ## When to Use ### aspect-ratio is ideal for - Images and image placeholders to prevent layout shift - Video embeds (YouTube, Vimeo iframes) - Card layouts that need consistent proportions - Avatar images (use `aspect-ratio: 1` for squares/circles) - Hero sections with fixed proportions - Any element that must maintain a width-to-height relationship ### Use object-fit alongside aspect-ratio when - The content (image or video) may not match the specified ratio - `cover` fills the box completely, cropping edges if needed - `contain` fits the entire content inside the box, leaving empty space ### Keep the padding-top hack only when - You must support Internet Explorer (end-of-life since June 2022) - Working in a legacy codebase that cannot be updated yet ## References - [aspect-ratio - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio) - [CSS aspect-ratio property - web.dev](https://web.dev/articles/aspect-ratio) - [Aspect Ratio Boxes - CSS-Tricks](https://css-tricks.com/aspect-ratio-boxes/) - [A closer look at the CSS aspect-ratio property - LogRocket Blog](https://blog.logrocket.com/a-closer-look-at-the-css-aspect-ratio/) --- # Multi-Column Layout > Source: https://takazudomodular.com/pj/zcss/docs/layout/specialized/multi-column-layout ## The Problem Creating newspaper-style multi-column text or masonry/Pinterest-style layouts has historically required JavaScript libraries or complex CSS Grid hacks. AI agents often reach for these heavier solutions without considering CSS Multi-Column Layout, a native CSS module that handles column-based content flow with just a few properties. Despite excellent browser support, multi-column layout remains widely underused — particularly for its ability to flow content vertically through columns without any JavaScript. ## The Solution CSS Multi-Column Layout splits content into multiple columns using the `columns` property system. Content flows top-to-bottom within each column, then continues at the top of the next column — exactly like a newspaper. This vertical flow is the key distinction from CSS Grid, which fills rows left-to-right. The module works for both inline text content (articles, paragraphs) and block-level elements (cards, images), making it versatile for a range of layout patterns. ### Core Principles #### column-count vs column-width The two fundamental approaches to defining columns: ```css /* Fixed number of columns */ .fixed-columns { column-count: 3; } /* Minimum column width — browser decides the count */ .flexible-columns { column-width: 250px; } /* Shorthand: column-width then column-count */ .shorthand { columns: 250px 3; /* At least 250px wide, at most 3 columns */ } ``` `column-count` creates exactly that many columns regardless of container width. `column-width` sets a minimum width — the browser calculates how many columns fit and may make them wider than the specified value, but never narrower. For responsive layouts, `column-width` alone is usually the better choice because it adapts to the viewport without media queries. #### Gap and Rules ```css .styled-columns { column-count: 3; column-gap: 2rem; /* Space between columns */ column-rule: 1px solid hsl(0 0% 80%); /* Vertical divider */ } ``` `column-gap` controls the space between columns (defaults to `1em`). `column-rule` draws a vertical line between columns, using the same syntax as `border`. The rule does not take up space — it is painted in the middle of the gap. #### break-inside for Preventing Card Splits When block-level elements (cards, figures, list items) are placed in a multi-column container, the browser may split them across column boundaries. Prevent this with: ```css .card { break-inside: avoid; } ``` This is essential for masonry-style card layouts where each card should remain intact within a single column. ## Code Examples ### Magazine Text Layout Classic newspaper-style multi-column text using `column-count` with `column-gap` and `column-rule`. Text flows naturally from one column to the next. Multi-column layout transforms ordinary text into a polished, magazine-style reading experience. The browser automatically balances content across columns, ensuring each column is roughly the same height. This second paragraph continues flowing into whichever column has space. Notice how the column rule provides a subtle visual separator between columns, improving readability without adding clutter. The column-gap property controls the breathing room between columns. A wider gap improves readability for longer-form content, while a tighter gap works well for short, scannable text blocks. Unlike CSS Grid, which places items into rows left-to-right, multi-column layout flows content top-to-bottom within each column before moving to the next. This is the natural reading pattern for newspaper and magazine articles. `} css={`.magazine-text { column-count: 3; column-gap: 1.5rem; column-rule: 1px solid hsl(220 15% 80%); font-family: Georgia, 'Times New Roman', serif; font-size: 14px; line-height: 1.6; color: hsl(220 15% 25%); padding: 16px; } .magazine-text p { margin: 0 0 0.75rem 0; }`} /> ### Width-Based Responsive Columns Using `column-width` without `column-count` lets the browser automatically determine how many columns fit. This is naturally responsive — no media queries needed. The specified width is a minimum; the browser may use wider columns but never narrower. This layout uses column-width instead of column-count. The browser calculates how many columns fit based on the container width. On a wide screen, you might see three or four columns. On a narrow screen, it may collapse to a single column. The column-width value acts as a minimum — columns will be at least this wide. If the container has extra space, the browser distributes it evenly, making each column wider than the minimum rather than adding another column that would be too narrow. This approach is ideal for responsive designs because the layout adapts to any container width without breakpoints. It is one of the simplest ways to create a responsive multi-column text layout in CSS. Try resizing the preview to see the columns reflow. At narrow widths the content will be in a single column. As the width grows, additional columns appear automatically. `} css={`.responsive-text { column-width: 180px; column-gap: 1.25rem; column-rule: 2px solid hsl(260 60% 70%); font-family: system-ui, sans-serif; font-size: 14px; line-height: 1.6; color: hsl(260 20% 20%); padding: 16px; } .responsive-text p { margin: 0 0 0.75rem 0; }`} /> ### Masonry-Style Card Gallery Block-level cards in a multi-column container with `break-inside: avoid` to prevent cards from splitting across columns. Cards have varying heights to show the masonry effect. Mountain Vista A breathtaking panoramic view of snow-capped peaks stretching across the horizon at dawn. City Lights Urban skyline at dusk. Ocean Waves Rolling waves crash against weathered rocks along a misty coastline. Forest Path A winding trail through ancient trees. Desert Sunset Golden light painting the sand dunes in warm amber tones as the sun dips below the flat horizon line. Garden Bloom Spring flowers in full color fill a terraced hillside garden with vibrant hues. Starry Night Clear skies reveal the milky way. Waterfall Crystal-clear water cascading down moss-covered rocks into a serene pool below. `} css={`.masonry-gallery { column-count: 3; column-gap: 12px; padding: 12px; } .masonry-card { break-inside: avoid; background: hsl(210 80% 55%); color: hsl(0 0% 100%); border-radius: 8px; padding: 14px; margin-bottom: 12px; font-family: system-ui, sans-serif; } .masonry-card h3 { margin: 0 0 6px 0; font-size: 15px; font-weight: 700; } .masonry-card p { margin: 0; font-size: 13px; line-height: 1.5; opacity: 0.9; } .masonry-card--medium { background: hsl(160 55% 42%); } .masonry-card--tall { background: hsl(280 55% 55%); }`} /> Without `break-inside: avoid`, the browser would split cards across column boundaries, cutting a card in half with the top in one column and the bottom in the next. This property is essential for any block-level content inside a multi-column container. ### Column Spanning Use `column-span: all` to create a full-width element that breaks out of the column flow. This is useful for headings, pull-quotes, or section dividers that should stretch across all columns. This is the opening paragraph of the article. The text flows across multiple columns in a traditional newspaper layout. Each column is balanced automatically by the browser. "Multi-column layout is the most underused CSS feature for long-form content." After the pull-quote, the column flow resumes. The browser creates a new column context below the spanning element. Content continues to fill columns from left to right. This technique is commonly used in magazine layouts where a key quote or image needs to break free from the column structure to create visual emphasis and break up long runs of text. Any element can span all columns — headings, images, horizontal rules, or custom dividers. Just apply column-span: all and the element stretches across the full container width. `} css={`.spanning-article { column-count: 3; column-gap: 1.25rem; column-rule: 1px solid hsl(220 15% 82%); font-family: Georgia, 'Times New Roman', serif; font-size: 13px; line-height: 1.6; color: hsl(220 15% 25%); padding: 16px; } .spanning-article p { margin: 0 0 0.75rem 0; } .pull-quote { column-span: all; margin: 1rem 0; padding: 1rem 1.5rem; background: hsl(45 90% 95%); border-left: 4px solid hsl(45 90% 50%); font-style: italic; font-size: 16px; color: hsl(45 40% 25%); }`} /> Note that `column-span` only accepts `all` or `none` — you cannot span a specific number of columns. The spanning element breaks the column flow, and a new column context begins below it. ### Columns vs Grid Comparison Multi-column layout and CSS Grid both create multi-column appearances, but they flow content in fundamentally different directions. Columns flow content **vertically** (top-to-bottom, then the next column), while Grid flows content **horizontally** (left-to-right, row by row). Multi-Column (vertical flow) 1 2 3 4 5 6 7 8 9 CSS Grid (horizontal flow) 1 2 3 4 5 6 7 8 9 `} css={`.comparison { display: flex; gap: 16px; padding: 12px; font-family: system-ui, sans-serif; } .comparison-panel { flex: 1; } .comparison-heading { font-size: 13px; font-weight: 700; color: hsl(220 15% 30%); margin: 0 0 10px 0; text-align: center; } .column-layout { column-count: 3; column-gap: 8px; } .grid-layout { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .numbered-item { padding: 10px; border-radius: 6px; text-align: center; font-size: 16px; font-weight: 700; color: hsl(0 0% 100%); } .numbered-item--col { background: hsl(210 80% 55%); margin-bottom: 8px; break-inside: avoid; } .numbered-item--grid { background: hsl(160 55% 42%); }`} /> In the multi-column layout, items are ordered 1-2-3 down the first column, 4-5-6 down the second, and 7-8-9 down the third. In the CSS Grid, items fill row by row: 1-2-3 across the first row, 4-5-6 across the second, and 7-8-9 across the third. **Use multi-column** when content should flow vertically like a newspaper — long text, image galleries, masonry card layouts. **Use CSS Grid** when items should fill rows left-to-right — product grids, dashboards, form layouts, or any design where horizontal order matters. ## Common AI Mistakes - **Ignoring multi-column layout entirely.** AI agents almost always reach for CSS Grid or JavaScript when asked to create masonry layouts or newspaper-style text. Multi-column layout is simpler and more appropriate for these use cases. - **Using `column-count` when `column-width` is better.** A fixed column count breaks on narrow viewports. `column-width` provides natural responsiveness without media queries. - **Forgetting `break-inside: avoid` on block-level content.** Without it, cards, images, and other block elements get split across column boundaries — a very common visual bug. - **Expecting `column-span` to accept numeric values.** Only `column-span: all` or `column-span: none` are valid. You cannot span 2 of 3 columns. - **Confusing multi-column flow direction with Grid.** Content flows top-to-bottom in multi-column layout. If horizontal (row-by-row) ordering is required, CSS Grid is the correct tool. - **Using `margin-bottom` without `break-inside: avoid`.** In a column container, margin on block items does not prevent column breaks. Always combine margins with `break-inside: avoid` for card-style layouts. ## When to Use ### Multi-Column Layout is ideal for - Newspaper or magazine-style text flowing across columns - Masonry/Pinterest-style layouts with varying-height cards - Lists that should distribute items across columns (navigation links, tag clouds) - Any content that should flow vertically through columns before moving to the next ### Use CSS Grid instead when - Items must fill rows left-to-right (product grids, dashboards) - You need precise control over both row and column placement - Items need to span specific rows and columns - Horizontal ordering of items matters to the design ## References - [CSS Multi-column Layout - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_multicol_layout) - [Guide to CSS Multi-Column Layout - CSS-Tricks](https://css-tricks.com/guide-responsive-friendly-css-columns/) - [columns - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/columns) - [break-inside - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/break-inside) - [column-span - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/column-span) --- # Component First Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/architecture/component-first-strategy ## The Problem When projects use a utility-first CSS framework like Tailwind CSS alongside a component framework (React, Vue, Svelte, etc.), developers and AI agents frequently fall back to traditional CSS patterns. Instead of composing utility classes inside components, they create custom CSS class names — `.profile-card`, `.btn-primary`, `.sidebar-nav` — with separate stylesheets or CSS modules. This creates a fragmented codebase: - Some components use Tailwind utilities inline - Others introduce custom CSS classes with BEM naming or CSS modules - Some mix both approaches in the same file The inconsistency makes the project harder to maintain. You can never be sure whether a piece of UI is styled with utilities, custom CSS, or a mix of both. For AI agents, this is a particularly common failure mode. Given a task like "build a profile card," an agent will often generate a `.profile-card` class with a CSS module — the pattern seen most often in training data. Even in a project that uses Tailwind exclusively, AI-generated code introduces custom CSS classes that drift from the project's conventions. Over time, the codebase becomes a patchwork of conflicting styling approaches. ## The Solution **Component First Strategy**: when your project uses a component-based framework with a utility CSS framework, always express UI as **components with utility classes**. Never create UI-level CSS class names with separate stylesheets. - Need a card? Create a `` component with utility classes - Need a button variant? Create a `` component - Need a layout pattern? Create a `` component The component itself is the abstraction. CSS class names like `.card` or `.btn-primary` are unnecessary — the component handles encapsulation, and utility classes handle styling. ## Code Examples ### Anti-Pattern: Custom CSS Classes in a Tailwind Project This is what you should **not** do in a component-based project with Tailwind. The developer has created custom CSS class names and a separate stylesheet — bypassing the utility framework entirely: ```jsx // ProfileCard.module.css // .profileCard { display: flex; gap: 1rem; padding: 1.5rem; ... } // .avatar { width: 64px; height: 64px; border-radius: 50%; ... } // .name { font-size: 1.25rem; font-weight: 600; ... } // .role { color: #6b7280; font-size: 0.875rem; ... } function ProfileCard({ name, role, avatar }) { return ( {name} {role} ); } ``` The CSS for this anti-pattern looks like traditional component CSS — custom class names, separate file, BEM-influenced naming: John Doe Developer `} css={` .profile-card { display: flex; gap: 1rem; padding: 1.5rem; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); font-family: system-ui, sans-serif; } .profile-card__avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; } .profile-card__body { display: flex; flex-direction: column; justify-content: center; } .profile-card__name { font-size: 1.25rem; font-weight: 600; margin: 0; color: #1e293b; } .profile-card__role { color: #6b7280; font-size: 0.875rem; margin: 0.25rem 0 0; } `} height={120} /> This works visually, but introduces naming decisions, a separate CSS file, and a styling approach that conflicts with the rest of the Tailwind-based project. ### Recommended: Component First with Utility Classes The same result, achieved with utility classes composed directly inside the component: ```jsx function ProfileCard({ name, role, avatar }) { return ( {name} {role} ); } ``` John Doe Developer `} css={`.flex { display: flex; } .flex-col { flex-direction: column; } .justify-center { justify-content: center; } .gap-4 { gap: 1rem; } .p-6 { padding: 1.5rem; } .m-0 { margin: 0; } .mt-1 { margin-top: 0.25rem; } .mb-0 { margin-bottom: 0; } .bg-white { background: #fff; } .rounded-lg { border-radius: 8px; } .rounded-full { border-radius: 9999px; } .shadow-md { box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .w-16 { width: 64px; } .h-16 { height: 64px; } .object-cover { object-fit: cover; } .text-xl { font-size: 1.25rem; } .text-sm { font-size: 0.875rem; } .font-semibold { font-weight: 600; } .text-slate-800 { color: #1e293b; } .text-gray-500 { color: #6b7280; } body { font-family: system-ui, sans-serif; }`} height={120} /> No CSS file. No class names to invent. The component encapsulates the visual design. When you need to change the card's look, you edit one file — the component. ### Component Variants with Props Instead of creating CSS modifier classes (`.btn--primary`, `.btn--secondary`), use component props to control variants: ```jsx function Button({ variant = 'primary', children, ...props }) { const styles = { primary: 'bg-blue-500 hover:bg-blue-700 text-white', secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800', outline: 'bg-transparent hover:bg-blue-50 text-blue-600 border border-blue-500', }; return ( {children} ); } ``` Usage is self-documenting: ```jsx Save Cancel Details ``` No `.btn-primary` class to maintain. The `variant` prop can be type-checked with TypeScript, documented with JSDoc, and auto-completed in your editor. The demo below uses CSS classes to approximate the visual output. In a real project, the variant logic lives in the component code and utility classes handle the styling — no custom CSS classes are created. Save Cancel Details `} css={`* { box-sizing: border-box; } body { font-family: system-ui, sans-serif; } .demo { display: flex; gap: 12px; padding: 20px; } .btn { font-weight: 600; padding: 0.5rem 1rem; border-radius: 0.25rem; border: none; font-size: 1rem; cursor: pointer; } .btn-primary { background-color: #3b82f6; color: #fff; } .btn-primary:hover { background-color: #1d4ed8; } .btn-secondary { background-color: #e5e7eb; color: #1f2937; } .btn-secondary:hover { background-color: #d1d5db; } .btn-outline { background-color: transparent; color: #2563eb; border: 1px solid #3b82f6; } .btn-outline:hover { background-color: #eff6ff; }`} height={80} /> ### Component Composition Complex layouts are built by composing smaller components — not by adding more CSS classes: ```jsx function UserList({ users }) { return ( {users.map((user) => ( {user.name} {user.email} {user.status} ))} ); } ``` Each piece — ``, ``, the list layout — is a component. No `.user-list__item`, `.user-list__avatar`, or `.user-list__badge` class names needed. ### Responsive Patterns in Components Utility frameworks use breakpoint prefixes for responsive behavior. These go directly in the component markup: ```jsx function ProductGrid({ products }) { return ( {products.map((product) => ( ))} ); } ``` Item 1 Item 2 Item 3 Item 4 Item 5 Item 6 `} css={`* { box-sizing: border-box; } body { font-family: system-ui, sans-serif; } .grid { display: grid; } .grid-cols-1 { grid-template-columns: 1fr; } .gap-4 { gap: 1rem; } .card { background: #8b5cf6; color: #fff; padding: 1.5rem; border-radius: 8px; text-align: center; font-size: 1rem; font-weight: 500; } @media (min-width: 480px) { .md\\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); } } @media (min-width: 720px) { .lg\\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); } }`} height={280} /> No separate CSS file for the grid. No `.product-grid` or `.product-grid--responsive` class. The responsive behavior is declared inline, visible in the same place as the markup. ## When Components Are Not Available The component-first approach requires the ability to create reusable components. In some situations this is not possible: - **Server-rendered HTML from a CMS** — the markup is fixed, you can only add stylesheets - **Third-party UI frameworks** — libraries that emit fixed HTML you cannot modify - **Email templates** — limited to inline styles and table layouts - **Static HTML sites without a build step** — no component framework available In these cases, fall back to other CSS strategies: | Situation | Recommended Approach | |---|---| | Have a build tool, can't change HTML | [CSS Modules](../css-modules-strategy) or Tailwind `@apply` | | No build tool, global CSS only | [BEM naming convention](../bem-strategy) | | Legacy codebase being migrated | Incremental component extraction | These are **exceptions**, not the default. If you are working in React, Vue, Svelte, Astro, or any framework that supports components, the component-first approach should be your default choice. ## Pros and Cons ### Pros - **No naming decisions.** The component name is the abstraction — no `.card-header` or `.btn-primary` to debate. - **Single source of truth.** Style and markup live in one file. Change the component, change everywhere it is used. - **AI-friendly.** AI agents can generate utility-based components reliably without guessing project-specific naming conventions. - **No CSS file management.** No separate stylesheets, no dead CSS, no import chains. - **Props replace modifiers.** `variant="primary"` is clearer than `.btn--primary` and supports TypeScript type-checking. - **Design consistency.** Utilities are tied to a design token scale (e.g., `p-4` = `1rem`, `p-6` = `1.5rem`), enforcing consistent spacing and sizing. ### Cons - **Verbose class lists.** Long utility strings in JSX can look cluttered. However, from 2026 onward, AI writes the code. Helpers like `clsx`/`cn` exist for human readability — there is no need to add them as dependencies in new projects. If an existing project already uses them, keep them, but do not introduce them for new work. - **Learning curve.** Developers unfamiliar with the utility framework need to learn its vocabulary. - **Requires a component framework.** Not applicable in plain HTML/CSS environments (see exceptions above). ## Component-Tier Variables Are Unnecessary In tiered design token strategies — such as a [three-tier color strategy](../../../styling/color/three-tier-color-strategy) or a [three-tier font-size strategy](../../../typography/font-sizing/three-tier-font-size-strategy) — the most concrete level is the **component tier**: CSS custom properties scoped to a specific component. For example, `--_dialog-side-spacing`, `--_card-shadow`, or `--_nav-font-size` (where the `--_` prefix signals local scope). These variables let a component encapsulate its own design decisions in its CSS file. With the component-first approach, this tier is unnecessary. There is no separate CSS file per component where you would define scoped custom properties. Styling is expressed as utility classes directly in the component's markup, and the component framework itself (React, Vue, Svelte, Astro) provides the scoping boundary. A `` component does not need a `--_dialog-side-spacing` variable — it uses `px-hsp-sm` or `px-hsp-md` directly (project-defined tokens from the [tight token strategy](../../design-systems/tight-token-strategy/)). A `` does not need `--_card-shadow` — it uses `shadow-md`. The component file is already the single source of truth for that component's styling decisions. The higher tiers (palette, theme, scale) still exist as global design tokens — only the component tier is eliminated, because the component architecture itself handles scoping. In contrast, when styling with general CSS approaches (BEM, CSS Modules), component-tier variables remain valuable. Without a component framework providing scoping, CSS custom properties are one of the few mechanisms for encapsulating design decisions within a component's stylesheet. ## When to Use **Always use the component-first approach when:** - Your project uses a component framework (React, Vue, Svelte, Astro, Solid, etc.) - Your project uses a utility CSS framework (Tailwind CSS, UnoCSS, etc.) **Fall back to other approaches only when:** - HTML cannot be changed (CMS output, third-party widgets, legacy markup) - No component framework is available (static HTML, email templates) - The project has explicitly chosen a different CSS convention and you should not mix approaches ### Rules for AI Agents When generating code for a component-based project with Tailwind: 1. **Always create components** — not CSS classes 2. **Use utility classes directly** in the component markup 3. **Never generate CSS module files** or custom class names unless explicitly asked 4. **Use props for variants** — not CSS modifiers like `.btn--primary` 5. **Compose components** — build complex UI from smaller components, not from more CSS ### Popular Tools - **[Tailwind CSS](https://tailwindcss.com/)** — The most popular utility-first framework. Uses a build step to generate only the CSS you use. - **[UnoCSS](https://unocss.dev/)** — An on-demand, fast alternative with a plugin-based architecture. Compatible with Tailwind presets. - **[clsx](https://github.com/lukeed/clsx)** / **[tailwind-merge](https://github.com/dcastil/tailwind-merge)** — Helpers for composing conditional utility classes. Useful in existing projects but unnecessary as a new dependency — AI tooling handles verbose class strings without readability helpers. ## References - [Tailwind CSS Documentation](https://tailwindcss.com/docs) - [Utility-First Fundamentals - Tailwind CSS](https://tailwindcss.com/docs/utility-first) - [Extracting Components and Partials - Tailwind CSS](https://tailwindcss.com/docs/reusing-styles#extracting-components-and-partials) - [UnoCSS Documentation](https://unocss.dev/guide/) - [CSS Utility Classes and "Separation of Concerns" - Adam Wathan](https://adamwathan.me/css-utility-classes-and-separation-of-concerns/) --- # Theming Recipes > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/custom-properties-advanced/theming-recipes Complete theme system recipes using CSS custom properties. Each recipe is a production-ready pattern you can adapt to your own projects. These recipes implement the layered architecture described in [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy) — palette tokens, semantic theme tokens, and component-scoped overrides — using the cascade and `var()` fallbacks. ## Light/Dark Theme with Custom Properties Define your entire color palette as custom properties, then swap them all with a single class toggle. No JavaScript logic for individual colors — the cascade handles everything. Light Theme Clean and bright for daytime reading. All colors come from custom properties. Action Dark Theme Easy on the eyes for nighttime use. Same markup, swapped properties. Action `} css={` .light-theme { --surface: hsl(220 20% 98%); --surface-raised: hsl(0 0% 100%); --text-primary: hsl(220 25% 15%); --text-secondary: hsl(220 15% 45%); --accent: hsl(220 80% 55%); --accent-hover: hsl(220 80% 45%); --accent-text: hsl(0 0% 100%); --border: hsl(220 15% 88%); } .dark-theme { --surface: hsl(225 25% 12%); --surface-raised: hsl(225 20% 18%); --text-primary: hsl(220 15% 90%); --text-secondary: hsl(220 15% 65%); --accent: hsl(220 80% 65%); --accent-hover: hsl(220 80% 75%); --accent-text: hsl(225 25% 12%); --border: hsl(225 15% 28%); } .theme-comparison { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .theme-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; } .theme-panel h3 { margin: 0 0 0.5rem; color: var(--text-primary); font-size: 1.05rem; } .theme-panel p { margin: 0 0 1.25rem; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.6; } .theme-btn { background: var(--accent); color: var(--accent-text); border: none; padding: 0.55rem 1.25rem; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: background 0.15s; } .theme-btn:hover { background: var(--accent-hover); } `} /> ## Brand Theme Override Start with a default theme, then override it by wrapping components in a brand-color container. The children automatically pick up the new values through the cascade. Default Brand Uses the base theme colors defined on :root. No overrides needed. Standard Custom Brand Wrapped in a brand override container — accent and surface swap instantly. Branded `} css={` :root { --brand-accent: hsl(220 75% 55%); --brand-accent-light: hsl(220 75% 95%); --brand-text: hsl(220 25% 20%); --brand-muted: hsl(220 15% 55%); } .brand-override { --brand-accent: hsl(280 70% 55%); --brand-accent-light: hsl(280 70% 95%); --brand-text: hsl(280 25% 20%); --brand-muted: hsl(280 15% 45%); } .brand-demo { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .brand-card { background: var(--brand-accent-light); border: 2px solid var(--brand-accent); border-radius: 12px; padding: 1.5rem; } .brand-card h3 { margin: 0 0 0.5rem; color: var(--brand-text); font-size: 1rem; } .brand-card p { margin: 0 0 1rem; color: var(--brand-muted); font-size: 0.85rem; line-height: 1.5; } .brand-badge { display: inline-block; background: var(--brand-accent); color: white; padding: 0.25rem 0.85rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; } `} /> ## Component API Pattern Expose a set of custom properties as a component's public styling API. Consumers override just the properties they need — the component handles the rest internally. Default Pill Green Wide Pink Square Orange Each button uses the same .api-btn class. Visual differences come entirely from custom property overrides via inline styles. `} css={` .api-demo { font-family: system-ui, sans-serif; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .api-btn { /* Public API */ --_bg: var(--btn-bg, hsl(220 75% 55%)); --_color: var(--btn-color, white); --_padding: var(--btn-padding, 0.6rem 1.5rem); --_radius: var(--btn-radius, 8px); background: var(--_bg); color: var(--_color); padding: var(--_padding); border-radius: var(--_radius); border: none; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: filter 0.15s; } .api-btn:hover { filter: brightness(0.9); } .api-code { margin-top: 1rem; } .api-code p { font-family: system-ui, sans-serif; font-size: 0.8rem; color: hsl(220 15% 55%); line-height: 1.5; } .api-code code { background: hsl(220 20% 94%); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## Surface/Content Layer Pattern Define three logical layers — **surface** (backgrounds), **content** (text), and **accent** (interactive highlights). Components reference layers instead of raw colors, making full-page theme swaps trivial. This is the [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy) in action — the layers below correspond to Tier 2 (semantic theme tokens). AppName Docs Blog About Navigation Getting Started Components Theming Welcome This layout uses a surface/content/accent layer system. Every section references layer tokens instead of hard-coded colors. Get Started `} css={` /* Layer definitions */ :root { --surface-base: hsl(220 20% 97%); --surface-raised: hsl(0 0% 100%); --surface-overlay: hsl(220 25% 93%); --content-primary: hsl(220 25% 15%); --content-secondary: hsl(220 15% 45%); --accent-base: hsl(250 70% 55%); --accent-hover: hsl(250 70% 45%); --accent-subtle: hsl(250 70% 95%); --accent-text: white; --border-subtle: hsl(220 15% 88%); } .layer-page { font-family: system-ui, sans-serif; background: var(--surface-base); border-radius: 12px; overflow: hidden; border: 1px solid var(--border-subtle); } .layer-header { background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle); padding: 0.75rem 1.25rem; display: flex; align-items: center; justify-content: space-between; } .layer-logo { font-weight: 700; color: var(--accent-base); font-size: 0.95rem; } .layer-nav { display: flex; gap: 1rem; } .layer-nav a { color: var(--content-secondary); text-decoration: none; font-size: 0.85rem; } .layer-nav a:hover { color: var(--accent-base); } .layer-body { display: grid; grid-template-columns: 10rem 1fr; } .layer-sidebar { background: var(--surface-overlay); padding: 1rem; border-right: 1px solid var(--border-subtle); } .layer-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.8rem; color: var(--content-secondary); text-transform: uppercase; letter-spacing: 0.05em; } .layer-sidebar ul { list-style: none; padding: 0; margin: 0; } .layer-sidebar li { margin-bottom: 0.25rem; } .layer-sidebar a { color: var(--content-primary); text-decoration: none; font-size: 0.85rem; display: block; padding: 0.3rem 0.5rem; border-radius: 6px; } .layer-sidebar a:hover { background: var(--accent-subtle); color: var(--accent-base); } .layer-content { padding: 1.5rem; background: var(--surface-raised); } .layer-content h2 { margin: 0 0 0.5rem; color: var(--content-primary); font-size: 1.1rem; } .layer-content p { margin: 0 0 1.25rem; color: var(--content-secondary); font-size: 0.85rem; line-height: 1.6; } .layer-action { background: var(--accent-base); color: var(--accent-text); border: none; padding: 0.55rem 1.25rem; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: background 0.15s; } .layer-action:hover { background: var(--accent-hover); } `} /> ## References - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Dark Mode in CSS - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/) - [Component-Level Art Direction with CSS Custom Properties - Sara Soueidan](https://www.sarasoueidan.com/blog/component-level-art-direction-with-css-custom-properties/) --- # Typography Token Patterns > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/tight-token-strategy/typography-tokens ## The Problem Tailwind CSS ships with 13 font-size steps (`text-xs` through `text-9xl`), 9 font-weight values (`font-thin` through `font-black`), 6+ line-height values, and multiple font-family options. Teams end up using `text-sm`, `text-base`, and `text-lg` interchangeably for body text, and `font-semibold` in one component while another uses `font-bold` for the same visual emphasis. This typography drift is harder to spot than spacing or color drift because the differences are subtle — 14px vs 16px body text, or `font-medium` vs `font-semibold` — but they accumulate into an interface that feels inconsistent without anyone being able to pinpoint why. ## The Solution Reset all default typography tokens and define a small set using **abstract size names**: - **Font sizes**: 6 sizes — `xs`, `sm`, `base`, `lg`, `xl`, `2xl` - **Font weights**: 3 weights — `normal`, `medium`, `bold` - **Line heights**: 3 values — `tight`, `normal`, `relaxed` - **Font families**: 2 families — `sans`, `mono` ### Why Abstract Names, Not Semantic Names A common first instinct is to name font-size tokens after their typographic role — `caption`, `body`, `subheading`, `heading`, `display`. This feels clean at first but creates a problem: **the token name hard-codes the usage context**. Consider: your `subheading` token is 20px. Now you need 20px text for a product price, a nav link, or an info callout. None of these are subheadings. You have two bad options: 1. **Use `text-subheading` for non-subheadings** — misleading, confuses other developers 2. **Create a new 20px token** with a different name — token bloat, same value Abstract names like `lg` solve this. Any element that needs 20px text uses `text-lg`. The role comes from context, not the token name. This parallels the [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy/) and the [Three-Tier Font-Size Strategy](../../../../typography/font-sizing/three-tier-font-size-strategy/): the core layer uses neutral, reusable names. Semantic names like `heading` or `caption` belong in the **theme layer** — as CSS custom properties or component-level tokens that reference core sizes: ```css /* Core layer (@theme) — abstract, reusable scale */ --font-size-lg: 1.25rem; /* Theme layer (project CSS) — semantic aliases */ :root { --font-subheading: var(--font-size-lg); } ``` ### The @theme Typography Block If you use [Approach B](../#approach-b-skip-the-default-theme-recommended) (separate imports without the default theme), no reset lines are needed — just define your tokens directly. If you use Approach A (`@import "tailwindcss"`), add the reset lines shown in comments below. ```css @theme { /* If using Approach A (@import "tailwindcss"), uncomment these resets: --font-size-*: initial; --font-weight-*: initial; --line-height-*: initial; --font-family-*: initial; --letter-spacing-*: initial; */ /* ── Font sizes with paired line-heights (6 steps) ── */ --font-size-xs: 0.75rem; /* 12px */ --font-size-xs--line-height: 1.5; --font-size-sm: 0.875rem; /* 14px */ --font-size-sm--line-height: 1.5; --font-size-base: 1rem; /* 16px */ --font-size-base--line-height: 1.75; --font-size-lg: 1.25rem; /* 20px */ --font-size-lg--line-height: 1.5; --font-size-xl: 1.75rem; /* 28px */ --font-size-xl--line-height: 1.25; --font-size-2xl: 2.5rem; /* 40px */ --font-size-2xl--line-height: 1.25; /* ── Font weights (3 steps) ── */ --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-bold: 700; /* ── Line heights (3 steps — for manual overrides) ── */ --line-height-tight: 1.25; --line-height-normal: 1.5; --line-height-relaxed: 1.75; /* ── Font families (2 families) ── */ --font-family-sans: "Inter", system-ui, sans-serif; --font-family-mono: "JetBrains Mono", ui-monospace, monospace; } ``` Each font size is **paired with an optimal line-height** via the `--font-size-*--line-height` convention. Writing `text-base` sets both `font-size: 1rem` and `line-height: 1.75` — no separate `leading-*` class needed. Standalone line-height tokens remain available for manual overrides when the paired value doesn't fit. After this configuration: - `text-sm` — **works** (resolves to `font-size: 0.875rem; line-height: 1.5`) - `text-3xl` — **build error** (no `--font-size-3xl` token exists) - `font-semibold` — **build error** (no `--font-weight-semibold` token exists) - `font-bold` — **works** (resolves to `font-weight: 700`) - `leading-normal` — **works** (resolves to `line-height: 1.5`) ## Demos ### Default Font Sizes vs Abstract Typography Tokens The left column shows Tailwind's 13 default font-size steps — from `text-xs` to `text-9xl`. The right column shows the 6 abstract sizes that replace them. Each token is a step in the scale, not tied to a specific UI role. Default sizes (all 13) text-xsThe quick brown fox text-smThe quick brown fox text-baseThe quick brown fox text-lgThe quick brown fox text-xlThe quick brown fox text-2xlThe quick brown fox text-3xlThe quick brown fox text-4xlQuick brown text-5xlQuick text-6xl60px text-7xl72px text-8xl96px text-9xl128px Abstract sizes (6) Fine print and labels xs — 12px Secondary text, descriptions sm — 14px Default body text size base — 16px Card titles, sub-sections lg — 20px Page headings xl — 28px Hero text 2xl — 40px `} css={`.demo { display: flex; gap: 24px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .col { flex: 1; min-width: 0; } .heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 10px; } .size-list { display: flex; flex-direction: column; gap: 2px; } .size-list.abstract { gap: 6px; } .size-row { display: flex; align-items: baseline; gap: 8px; min-height: 22px; } .size-row.dim { opacity: 0.4; } .token { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); width: 56px; flex-shrink: 0; } .hint { font-size: 11px; color: hsl(215 16% 47%); } .sample { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; } /* Abstract column */ .size-row-lg { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px; border-left: 3px solid hsl(221 83% 53%); } .token-right { font-size: 10px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; }`} /> ### Typography Scale Card A visual hierarchy card showing all 6 abstract sizes with their paired line-heights. This is the complete typographic vocabulary of the project — reusable in any context. Typography Scale Display 2xl — 40px · line-height 1.25 Heading xl — 28px · line-height 1.25 Subheading lg — 20px · line-height 1.5 Body text for paragraphs and descriptions. base — 16px · line-height 1.75 Secondary text and supporting details sm — 14px · line-height 1.5 Caption text for labels and timestamps xs — 12px · line-height 1.5 `} css={`.scale-card { max-width: 560px; margin: 16px auto; border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .scale-header { padding: 12px 20px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); } .scale-body { padding: 16px 20px; } .scale-row { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; } .divider { height: 1px; background: hsl(214 32% 91%); } .scale-sample { line-height: 1.2; } .scale-sample.sz-2xl { font-size: 40px; font-weight: 700; line-height: 1.25; } .scale-sample.sz-xl { font-size: 28px; font-weight: 700; line-height: 1.25; } .scale-sample.sz-lg { font-size: 20px; font-weight: 500; line-height: 1.5; } .scale-sample.sz-base { font-size: 16px; font-weight: 400; line-height: 1.75; } .scale-sample.sz-sm { font-size: 14px; font-weight: 400; line-height: 1.5; color: hsl(215 16% 47%); } .scale-sample.sz-xs { font-size: 12px; font-weight: 400; line-height: 1.5; color: hsl(215 16% 47%); } .scale-meta { display: flex; flex-direction: column; gap: 1px; } .scale-specs { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; }`} /> ### Same Size, Different Roles The key advantage of abstract names: `text-lg` works equally well for a card title, a price, and a nav link. With semantic names like `subheading`, you would need three separate tokens for the same 20px value. Card title Premium Plan text-lg Product price $49.99 text-lg Nav link Documentation text-lg `} css={`.roles-demo { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .role-card { flex: 1; padding: 14px 16px; border: 1px solid hsl(214 32% 91%); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; } .role-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .role-sample { line-height: 1.3; } .role-token { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; background: hsl(210 40% 96%); padding: 2px 6px; border-radius: 3px; align-self: flex-start; }`} /> ### Article Layout with Abstract Typography Tokens A complete article layout using only the 6 abstract font sizes, 3 weights, and 3 line-heights. Notice how `text-lg` is reused for both the subtitle and the section heading — same size, different roles. With semantic naming, you would need separate `subtitle` and `section-heading` tokens for the same value. March 4, 2026 Building Consistent Interfaces How typography tokens eliminate visual drift across teams When every developer on the team reaches for different font sizes, the interface develops subtle inconsistencies. One component uses 14px body text, another uses 16px, and a third uses 18px. None are wrong — they are all valid Tailwind utilities — but the result feels fragmented. The Six-Size Approach By constraining the type scale to six abstract sizes, every text element maps to a clear step in the scale. There is no ambiguity about which size to pick — and no awkward semantic mismatch when the same size serves a different role. Key Insight Six font sizes, three weights, and three line-heights produce a type system that covers every common UI pattern — from timestamps to hero text — without naming any of them after a specific role. Token names describe scale position (xs → 2xl), not usage. The role comes from context. Design Systems Typography 5 min read `} css={`.article { max-width: 540px; margin: 16px auto; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .article-header { padding: 24px 24px 16px; } .article-date { font-size: 12px; /* xs */ font-weight: 400; /* normal */ line-height: 1.5; /* xs paired */ color: hsl(215 16% 47%); display: block; margin-bottom: 6px; } .article-title { font-size: 28px; /* xl */ font-weight: 700; /* bold */ line-height: 1.25; /* xl paired */ margin: 0 0 6px 0; } .article-subtitle { font-size: 20px; /* lg — same token as h2 below */ font-weight: 500; /* medium */ line-height: 1.5; /* lg paired */ color: hsl(215 16% 47%); margin: 0; } .article-body { padding: 0 24px 20px; } .article-paragraph { font-size: 16px; /* base */ font-weight: 400; /* normal */ line-height: 1.75; /* base paired */ margin: 0 0 16px 0; color: hsl(222 47% 11%); } .article-h2 { font-size: 20px; /* lg — same token as subtitle above */ font-weight: 700; /* bold */ line-height: 1.25; /* tight (override) */ margin: 20px 0 10px 0; } .article-callout { background: hsl(210 40% 96%); border-left: 3px solid hsl(221 83% 53%); padding: 12px 16px; border-radius: 0 6px 6px 0; margin: 16px 0; } .callout-label { font-size: 12px; /* xs */ font-weight: 700; /* bold */ line-height: 1.5; /* xs paired */ color: hsl(221 83% 53%); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; } .callout-text { font-size: 16px; /* base */ font-weight: 400; /* normal */ line-height: 1.75; /* base paired */ margin: 0; color: hsl(222 47% 11%); } .article-aside { font-size: 14px; /* sm */ font-weight: 400; /* normal */ line-height: 1.5; /* sm paired */ color: hsl(215 16% 47%); margin: 0; font-style: italic; } .article-footer { padding: 12px 24px; background: hsl(210 40% 96%); border-top: 1px solid hsl(214 32% 91%); display: flex; align-items: center; gap: 8px; } .footer-tag { font-size: 12px; /* xs */ font-weight: 500; /* medium */ background: hsl(221 83% 53%); color: hsl(210 40% 98%); padding: 3px 10px; border-radius: 99px; } .footer-read { font-size: 12px; /* xs */ font-weight: 400; /* normal */ color: hsl(215 16% 47%); margin-left: auto; }`} /> ## When to Use Typography tokens pair naturally with [color tokens](./color-tokens) and the [spacing strategy](./index.mdx) from the parent article. Together, they form the core of a tight design token system that constrains the three most common sources of visual drift. Apply typography tokens when: - The project uses more than 3 font sizes in practice — constrain them to exactly 6 - Multiple developers write markup and each reaches for different text sizes - You want font-size tokens that are reusable across any context — not tied to a specific component role ## Related Articles - [Three-Tier Font-Size Strategy](../../../../typography/font-sizing/three-tier-font-size-strategy/) — The full conceptual architecture behind these token choices (scale → theme → component) - [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy/) — The same three-tier architecture applied to colors - [Color Token Patterns](./color-tokens) — Semantic color scales using the same tight-token approach ## References - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # Two-Tier Size Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/two-tier-size-strategy ## The Problem When using the [tight token strategy](./tight-token-strategy/), all Tailwind defaults are reset — including the numeric spacing scale that powers `h-4`, `w-4`, `size-8`, and similar width/height utilities. The moment someone needs to size an icon or set a card width, these classes stop working. The instinct — especially from AI agents — is to re-add numeric spacing tokens: ```css @theme { /* "Just add back what we need" */ --spacing-3: 12px; --spacing-4: 16px; --spacing-5: 20px; --spacing-8: 32px; --spacing-10: 40px; --spacing-16: 64px; } ``` This defeats the purpose. It re-imports Tailwind's default numeric scale with no semantic value. `h-4 w-4` tells you nothing — is it an icon? A spacer? A decorative element? You are back to the same problem the tight token strategy was designed to solve. ### Why This Property Is Different Some CSS properties have natural scales worth abstracting: - **Spacing** (padding, margin, gap) — has a consistent rhythm across the UI → semantic axes (hsp/vsp) make sense - **Font sizes** — has a clear hierarchy from captions to headings → an abstract scale (xs–2xl) makes sense - **Colors** — has a palette of raw values that get mapped to roles → three tiers make sense **Width/height is different.** A 16px icon, a 40px avatar, a 320px card, and a 64px sidebar toggle have nothing in common. There is no natural progression, no rhythm, no hierarchy. An abstract scale like `size-4`, `size-8`, `size-16` is just arbitrary numbers pretending to be meaningful. ## The Solution Use a **two-tier approach** — skip the abstract scale entirely: | Tier | Name | Purpose | Example | | --- | --- | --- | --- | | 1 | **Theme** | Design-level sizes that define the visual system | `--icon-sm: 16px` | | 2 | **Component** | One-off sizes specific to one component | `w-[28px] h-[28px]` | The key insight: **there is no Tier 0 (abstract scale) for width/height.** Semantic names are the first and only token layer. Everything else is an arbitrary value. ### Tier 1: Theme Tokens Define tokens when a size represents a **design decision** — a deliberate choice about how the UI is structured: ```css @theme { /* Icon sizes — a design system decision */ --spacing-icon-sm: 16px; --spacing-icon-md: 20px; --spacing-icon-lg: 24px; /* Avatar sizes — a design system decision */ --spacing-avatar-sm: 32px; --spacing-avatar-md: 40px; --spacing-avatar-lg: 56px; /* Layout sizes — an architectural decision */ --spacing-content-width: 800px; --spacing-card-width: 300px; } ``` This generates utilities like `w-icon-md h-icon-md`, `w-avatar-sm h-avatar-sm`, and `max-w-content-width` — self-documenting class names that tell you exactly what the size is for. Whether to create a token is an **architectural and design judgment**, not a usage-count threshold. You define `--spacing-content-width: 800px` because your design says "the main column is 800px" — even if only one component uses it today. It's the same kind of decision as choosing a brand color or a type scale: it comes from the design, not from counting how many files reference a value. This is similar to the "should we extract this to a utility function?" debate in application architecture. The answer isn't "when 3 files import it" — it's about whether the concept deserves a name in the system. ### Tier 2: Arbitrary Values For everything else — one-off component dimensions, calculated layouts, structural details — use Tailwind's bracket syntax: ```html ... ... ... ``` When a value is purely a structural detail of one component (a button's exact padding, a grid's column template), arbitrary values are the right choice. When a value represents a design decision that defines the system, it belongs in Tier 1 — regardless of how many components currently use it. ## Demos ### The Wrong Approach: Re-importing Numeric Sizes This demo shows what happens when you re-add Tailwind's numeric spacing scale for width/height. The class names are meaningless — `size-4`, `size-5`, `size-8` tell you nothing about what they're sizing. @theme tokens added --spacing-3: 12px --spacing-4: 16px --spacing-5: 20px --spacing-8: 32px --spacing-10: 40px --spacing-16: 64px Usage in components h-4 w-4 ← Icon? Spacer? Dot? h-5 w-5 ← Bigger icon? Badge? h-8 w-8 ← Avatar? Button? Thumbnail? h-10 w-10 ← Who knows? Numbers tell you how big, but not what for. This is the spacing-drift problem all over again. `} css={`.wrong-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .wrong-demo__section { display: flex; flex-direction: column; gap: 0.4rem; } .wrong-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .wrong-demo__tokens { display: flex; flex-wrap: wrap; gap: 4px; } .wrong-demo__tokens code { font-size: 0.65rem; background: hsl(0 80% 95%); color: hsl(0 60% 40%); padding: 2px 6px; border-radius: 3px; border: 1px solid hsl(0 60% 85%); } .wrong-demo__examples { display: flex; flex-direction: column; gap: 6px; } .wrong-demo__row { display: flex; align-items: center; gap: 8px; } .wrong-demo__box { background: hsl(215 16% 47%); border-radius: 3px; flex-shrink: 0; } .wrong-demo__row code { font-size: 0.7rem; font-weight: 600; color: hsl(222 47% 11%); width: 56px; } .wrong-demo__q { font-size: 0.65rem; color: hsl(0 60% 50%); font-style: italic; } .wrong-demo__verdict { font-size: 0.7rem; color: hsl(0 60% 40%); background: hsl(0 80% 95%); padding: 0.4rem 0.6rem; border-radius: 6px; border-left: 3px solid hsl(0 60% 50%); line-height: 1.4; }`} /> ### The Right Approach: Theme Tokens for Shared Sizes With semantic theme tokens, the class names are self-documenting. Every developer knows what `w-icon-md h-icon-md` means — and changing the icon size updates every component at once. @theme tokens --spacing-icon-sm: 16px --spacing-icon-md: 20px --spacing-icon-lg: 24px --spacing-avatar-sm: 32px --spacing-avatar-md: 40px Toolbar w-icon-md h-icon-md Comment T Takeshi Great article! w-avatar-sm h-avatar-sm Header App T Same tokens, different component `} css={`.right-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .right-demo__section { display: flex; flex-direction: column; gap: 0.4rem; } .right-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .right-demo__tokens { display: flex; flex-wrap: wrap; gap: 4px; } .right-demo__tokens code { font-size: 0.65rem; background: hsl(142 50% 93%); color: hsl(142 50% 30%); padding: 2px 6px; border-radius: 3px; border: 1px solid hsl(142 40% 78%); } .right-demo__components { display: flex; gap: 12px; } .right-demo__comp { flex: 1; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem; display: flex; flex-direction: column; gap: 0.4rem; } .right-demo__comp-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .right-demo__toolbar { display: flex; gap: 6px; } .right-demo__icon-btn { padding: 6px; background: hsl(210 40% 96%); border-radius: 6px; } .right-demo__icon { background: hsl(221 83% 53%); border-radius: 3px; } .right-demo__comment { display: flex; gap: 8px; align-items: flex-start; } .right-demo__avatar { background: hsl(221 83% 53%); border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: hsl(0 0% 100%); font-size: 0.7rem; font-weight: 700; } .right-demo__comment-body { min-width: 0; } .right-demo__comment-name { font-size: 0.72rem; font-weight: 600; } .right-demo__comment-text { font-size: 0.68rem; color: hsl(215 16% 47%); } .right-demo__header { display: flex; justify-content: space-between; align-items: center; background: hsl(222 47% 11%); color: hsl(0 0% 100%); padding: 0.4rem 0.6rem; border-radius: 6px; } .right-demo__header-logo { font-size: 0.75rem; font-weight: 700; } .right-demo__header-icons { display: flex; gap: 8px; align-items: center; } .right-demo__usage { font-size: 0.6rem; color: hsl(142 50% 30%); background: hsl(142 50% 93%); padding: 2px 6px; border-radius: 3px; align-self: flex-start; }`} /> ### Side-by-Side: Abstract Numbers vs Semantic Names The same UI elements sized two ways. On the left, abstract numeric tokens that could mean anything. On the right, semantic theme tokens that tell you exactly what they're for. Abstract numbers size-4 icon? T size-8 avatar? size-5 bigger icon? T size-10 bigger avatar? Numbers don't communicate intent Semantic names icon-sm small icon T avatar-sm small avatar icon-md medium icon T avatar-md medium avatar Names tell you what it's for `} css={`.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .compare__col { display: flex; flex-direction: column; gap: 0.5rem; } .compare__heading { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .compare__card { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem; display: flex; flex-direction: column; gap: 8px; flex: 1; } .compare__row { display: flex; align-items: center; gap: 8px; } .compare__box { background: hsl(215 16% 47%); border-radius: 3px; flex-shrink: 0; } .compare__circle { background: hsl(221 83% 53%); border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: hsl(0 0% 100%); font-size: 0.65rem; font-weight: 700; } .compare__row code { font-size: 0.68rem; font-weight: 600; width: 72px; } .compare__what { font-size: 0.62rem; color: hsl(0 60% 50%); font-style: italic; } .compare__clear { font-size: 0.62rem; color: hsl(142 50% 30%); } .compare__note { font-size: 0.65rem; padding: 0.3rem 0.5rem; border-radius: 4px; line-height: 1.3; } .compare__note--bad { background: hsl(0 80% 95%); color: hsl(0 60% 40%); border-left: 3px solid hsl(0 60% 50%); } .compare__note--good { background: hsl(142 50% 93%); color: hsl(142 50% 30%); border-left: 3px solid hsl(142 50% 40%); }`} /> ### Tier 2 in Practice: Arbitrary Values for One-Offs Component-specific sizing uses Tailwind's bracket syntax. These values stay in the component — they are not promoted to tokens because they have no meaning outside their context. Icon button — custom padding for visual balance w-[28px] h-[28px] p-[4px] Sidebar — structural width Sidebar w-[240px] Grid layout — template columns Nav Content area grid-cols-[200px_1fr] Main content — calculated height Main h-[calc(100vh-64px)] All arbitrary — unique to one component, not worth tokenizing `} css={`.arb-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.6rem; } .arb-demo__section { display: flex; flex-direction: column; gap: 4px; } .arb-demo__label { font-size: 0.65rem; color: hsl(215 16% 47%); } .arb-demo__row { display: flex; align-items: center; gap: 10px; } .arb-demo__row code { font-size: 0.65rem; font-weight: 600; color: hsl(221 83% 53%); background: hsl(210 40% 96%); padding: 2px 6px; border-radius: 3px; } .arb-demo__icon-btn { width: 28px; height: 28px; padding: 4px; background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .arb-demo__icon { background: hsl(221 83% 53%); border-radius: 3px; } .arb-demo__sidebar { width: 80px; height: 28px; background: hsl(222 47% 11%); color: hsl(0 0% 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 600; } .arb-demo__grid { display: grid; grid-template-columns: 60px 1fr; width: 160px; height: 28px; border: 1px solid hsl(214 32% 91%); border-radius: 4px; overflow: hidden; } .arb-demo__grid-col1 { background: hsl(210 40% 96%); font-size: 0.6rem; display: flex; align-items: center; justify-content: center; border-right: 1px solid hsl(214 32% 91%); } .arb-demo__grid-col2 { font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: hsl(215 16% 47%); } .arb-demo__calc { width: 120px; height: 28px; background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: hsl(215 16% 47%); } .arb-demo__note { font-size: 0.65rem; color: hsl(215 16% 47%); font-style: italic; }`} /> ## Tailwind CSS Integration In a Tailwind v4 project: **Tier 1** → `@theme` block with `--spacing-*` prefix (so they work with `w-*` and `h-*` utilities): ```css @theme { /* Element sizing tokens — only shared, semantic sizes */ --spacing-icon-sm: 16px; --spacing-icon-md: 20px; --spacing-icon-lg: 24px; --spacing-avatar-sm: 32px; --spacing-avatar-md: 40px; --spacing-avatar-lg: 56px; } ``` Usage: `w-icon-md h-icon-md`, `w-avatar-sm h-avatar-sm`. **Tier 2** → Tailwind's bracket syntax for one-offs: ```html ... ... ... ``` ## Common AI Mistakes - **Re-adding the numeric spacing scale** — importing `--spacing-4: 16px`, `--spacing-8: 32px` etc. back into `@theme` defeats the tight token strategy; these are meaningless numbers - **Creating abstract size tokens** — `--size-sm`, `--size-md`, `--size-lg` with values like 16px, 32px, 64px are too generic; what is a "small size"? An icon? An avatar? A button? - **Using spacing tokens for element sizing** — `hsp-sm` is for horizontal padding/margins, not for icon width; different concepts, different tokens - **Adding a token for every unique dimension** — not every pixel value deserves a name; a calculated offset like `top-[calc(100%-2px)]` is a structural detail, not a design decision - **Porting Tailwind defaults "just in case"** — adding numeric spacing tokens without a design reason creates the unconstrained palette the tight strategy was designed to prevent; tokens should come from design decisions, not from anticipating future usage ## When to Use - **Any project using the tight token strategy** that needs to size elements (icons, avatars, cards, thumbnails) - **When AI agents build components** — they consistently reach for `h-4 w-4` which doesn't exist in tight token projects; this article explains the correct approach - **When the team debates adding numeric size tokens** — this article provides the reasoning for why not ### Tailwind + Component-First vs General CSS The two-tier approach works the same way regardless of CSS methodology, but the mechanism for Tier 2 differs: - **Tailwind + component-first** — Tier 2 values are Tailwind bracket syntax in JSX: `w-[28px]`, `grid-cols-[240px_1fr]`. The component file provides scoping. No CSS custom properties needed. - **General CSS (BEM, CSS Modules)** — Tier 2 values are component-scoped CSS custom properties: `--_button-width: 28px`, `--_grid-sidebar: 240px`. The underscore prefix (`--_`) signals local scope. ### Contrast with Other Token Strategies Not every CSS property needs the same number of tiers: | Property | Tiers | Why | | --- | --- | --- | | Colors | 3 (palette → theme → component) | Raw values have palette structure worth abstracting | | Font sizes | 3 (scale → theme → component) | Clear hierarchy from captions to headings | | Spacing | 2 (semantic axes hsp/vsp) | Consistent rhythm, but already semantic | | **Width/height** | **2 (theme → component)** | **No natural scale — semantic names only** | See also: - [Three-Tier Color Strategy](../color/three-tier-color-strategy/) — Full three-tier architecture for colors - [Three-Tier Font-Size Strategy](../../typography/font-sizing/three-tier-font-size-strategy/) — Three-tier architecture for font sizes - [Component Tokens & Arbitrary Values](./tight-token-strategy/component-tokens/) — General framework for when to use tokens vs arbitrary values ## References - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 Arbitrary Values](https://tailwindcss.com/docs/adding-custom-styles#using-arbitrary-values) --- # What is zudo-css? > Source: https://takazudomodular.com/pj/zcss/docs/overview/what-is-zudo-css zudo-css is a CSS best practices documentation site designed primarily for AI coding agents. It provides curated CSS techniques and patterns as structured references that AI agents can consume and apply during development. ## Purpose AI coding agents frequently produce CSS that works but follows outdated patterns, overcomplicates simple tasks, or misses modern CSS features. This documentation addresses that gap by providing: - Problem-first articles that start with what goes wrong - Live CssPreview demos showing each technique in action - Decision guidance for choosing between approaches - Common AI mistakes sections highlighting specific pitfalls ## Tech Stack The site is built with: - **Astro 5** — static site generator with Content Collections - **MDX** — content format combining Markdown with interactive components - **Tailwind CSS v4** — utility-first styling via `@tailwindcss/vite` - **React 19** — interactive islands for TOC, sidebar, and color scheme picker - **Shiki** — syntax highlighting with dual-theme support Deployed to **Cloudflare Pages** via GitHub Actions. ## Article Categories Articles are organized by CSS domain: | Category | Topics | | --- | --- | | Layout | Centering, Flexbox, Grid, subgrid, positioning, stacking context, anchor positioning, aspect-ratio, logical properties, gap vs margin, multi-column, fit/max/min-content, clamp() | | Typography | Three-tier font-size strategy, fluid font sizing, line height, text overflow/clamping, vertical rhythm, font loading, variable fonts, Japanese fonts, text-wrap balance/pretty | | Color | Three-tier color strategy, palette strategy, OKLCH, color-mix(), currentColor, dark mode, contrast/accessibility | | Visual | Layered shadows, smooth shadow transitions, gradients, borders, clip-path/mask, filters, backdrop-filter, blend modes, 3D transforms, @property, CSS-only patterns | | Responsive | Container queries, fluid design with clamp(), media query best practices, responsive grid patterns, responsive images | | Interactive | Transitions, hover/focus/active states, scroll snap, scroll-driven animations, view transitions, :has(), :is()/:where(), parent-state child styling, form controls, touch targets, overscroll, prefers-reduced-motion | | Methodology | Component-first strategy, tight token strategy (component tokens, typography tokens, color tokens), two-tier size strategy, BEM, CSS Modules, cascade layers, custom properties patterns, theming recipes | ## Article Structure Every article follows a consistent pattern: 1. **The Problem** — common mistakes and what goes wrong 2. **The Solution** — recommended approach with CssPreview demos 3. **Additional sections** — deeper techniques with more demos 4. **When to Use** — decision guidance for applying the technique ## CssPreview Demos The most valuable part of each article. CssPreview renders CSS demos inside isolated iframes with viewport controls (Mobile 320px, Tablet 768px, Full width). All interactions are CSS-only — no JavaScript inside demos. ## AI Integration The site includes a `css-wisdom` Claude Code skill that indexes all articles. Once installed, AI agents can invoke `/css-wisdom ` to look up relevant CSS patterns during development. See the [css-wisdom Skill](../css-wisdom-skill) page for details. --- # Fluid Design with clamp() > Source: https://takazudomodular.com/pj/zcss/docs/responsive/fluid-design-with-clamp ## The Problem Traditional responsive design relies on discrete breakpoints: fixed values at specific viewport widths with abrupt jumps between them. This creates jarring transitions and requires multiple media queries to manage. AI agents typically generate rigid breakpoint-based values (e.g., `font-size: 1rem` on mobile, `font-size: 1.5rem` on desktop) instead of using fluid scaling, resulting in more code and a less polished experience. ## The Solution The CSS `clamp()` function defines a value that scales fluidly between a minimum and maximum, based on a preferred value that usually involves viewport units. This eliminates the need for breakpoints for many sizing concerns. ```css /* clamp(minimum, preferred, maximum) */ font-size: clamp(1rem, 0.5rem + 1.5vw, 2rem); ``` - **Minimum**: The smallest the value will ever be. - **Preferred**: The fluid middle value, typically using `vw` combined with a `rem` base. - **Maximum**: The largest the value will ever be. Fluid Heading This text and its container use clamp() for fluid sizing. The heading, paragraph text, padding, and container width all scale smoothly between minimum and maximum values based on viewport width. No breakpoints needed. Fluid padding & width `} css={` .fluid-demo { max-width: clamp(16rem, 90%, 50rem); margin: 0 auto; padding: clamp(0.75rem, 0.5rem + 2vw, 2.5rem); } .fluid-heading { font-size: clamp(1.25rem, 0.75rem + 2.5vw, 2.5rem); font-weight: 700; color: #1e293b; margin: 0 0 0.75rem; line-height: 1.2; } .fluid-text { font-size: clamp(0.875rem, 0.8rem + 0.25vw, 1.125rem); line-height: 1.6; color: #475569; margin: 0 0 1.5rem; } .fluid-box { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; padding: clamp(0.75rem, 0.5rem + 1.5vw, 2rem); border-radius: 0.5rem; font-size: clamp(0.75rem, 0.65rem + 0.5vw, 1.125rem); font-weight: 600; text-align: center; } `} /> ## Code Examples ### Fluid Typography ```css h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } h2 { font-size: clamp(1.375rem, 0.875rem + 1.5vw, 2.25rem); } h3 { font-size: clamp(1.125rem, 0.75rem + 1vw, 1.75rem); } p { font-size: clamp(1rem, 0.875rem + 0.25vw, 1.125rem); } ``` ### Fluid Spacing ```css .section { padding-block: clamp(2rem, 1rem + 3vw, 5rem); padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem); } .stack > * + * { margin-block-start: clamp(1rem, 0.5rem + 1vw, 2rem); } ``` ### Fluid Layout Dimensions ```css .container { max-width: clamp(20rem, 90vw, 75rem); margin-inline: auto; } .sidebar { width: clamp(15rem, 25vw, 20rem); } .gap-fluid { gap: clamp(0.5rem, 0.25rem + 1vw, 2rem); } ``` ### Fluid Line Height and Letter Spacing ```css p { font-size: clamp(1rem, 0.875rem + 0.25vw, 1.125rem); line-height: clamp(1.5, 1.4 + 0.2vw, 1.8); letter-spacing: clamp(0px, 0.02em + 0.01vw, 0.04em); } ``` ### Building the Preferred Value The preferred value formula follows a pattern: ``` preferred = base-rem-value + viewport-unit-value ``` To calculate values that scale between two specific viewport widths: ```css /* Scale from 1rem at 320px to 2rem at 1200px: Slope = (max - min) / (max-viewport - min-viewport) Slope = (2 - 1) / (75 - 20) = 0.01818rem per rem of viewport In vw: 0.01818 * 100 = 1.818vw Intercept = min - slope * min-viewport Intercept = 1 - 0.01818 * 20 = 0.636rem Result: clamp(1rem, 0.636rem + 1.818vw, 2rem) */ .fluid-text { font-size: clamp(1rem, 0.636rem + 1.818vw, 2rem); } ``` ## Common AI Mistakes - **Using only breakpoints**: Generating multiple `@media` queries with fixed values instead of a single `clamp()` expression. - **Using `vw` alone without `rem`**: Writing `font-size: clamp(1rem, 3vw, 2rem)` where the preferred value is pure `vw`. This prevents the value from scaling with user font-size preferences. Always combine `vw` with a `rem` base. - **Unrealistic min/max bounds**: Setting bounds too close together (no visible fluid range) or too far apart (text becomes unreadable at extremes). - **Forgetting accessibility**: Fluid typography using `vw` units can interfere with browser zoom. Always test zoom to 200% and ensure text remains readable. - **Using `clamp()` where a simple `max-width` suffices**: Not every value needs to be fluid. Use `clamp()` where smooth scaling improves the experience. - **Using pixel values for min/max**: Pixels do not scale with user font-size settings. Prefer `rem` for min and max values. ## When to Use - **Typography**: Heading and body font sizes that should scale smoothly between mobile and desktop. - **Spacing**: Padding, margins, and gaps that should grow proportionally with the viewport. - **Layout widths**: Container widths, sidebar widths, and max-widths that need fluid behavior. - **Not for colors or discrete values**: `clamp()` works with numeric CSS values. It does not apply to properties like `display`, `color`, or `grid-template-columns` patterns. ## References - [clamp() — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Modern Fluid Typography Using CSS Clamp — Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Fluid Typography with CSS Clamp — XenonStack](https://www.xenonstack.com/blog/fluid-typography-css-clamp) - [CSS Clamp Guide — Clamp Generator](https://clampgenerator.com/guides/css-clamp/) --- # color-mix() > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/color-mix ## The Problem Creating tints, shades, and semi-transparent color variants in CSS traditionally requires manually calculating each color value. AI agents frequently hard-code every shade as a separate hex or rgb value (e.g., generating `#3366cc`, `#5588dd`, `#99bbee` independently), making palettes brittle and difficult to maintain. When the base brand color changes, every derived value must be recalculated. CSS preprocessors like Sass solved this with `lighten()` and `darken()`, but native CSS had no equivalent — until `color-mix()`. ## The Solution `color-mix()` is a CSS function that blends two colors in a specified color space and returns the resulting color. It takes a color space, two colors, and optional percentage values that control the mix ratio. ``` color-mix(in , ?, ?) ``` The color space parameter determines how the interpolation is calculated. Using `oklch` or `oklab` produces more perceptually uniform blends than `srgb`. ## Code Examples ### Basic Syntax ```css :root { --brand: oklch(55% 0.25 264); /* Mix 70% brand with 30% white = a lighter tint */ --brand-light: color-mix(in oklch, var(--brand) 70%, white); /* Mix 70% brand with 30% black = a darker shade */ --brand-dark: color-mix(in oklch, var(--brand) 70%, black); /* Equal mix of two colors */ --blend: color-mix(in oklch, var(--brand), orange); } ``` ### Creating Tints and Shades from a Single Base Color ```css :root { --brand: oklch(50% 0.22 264); /* Tints (lighter) — mixing with white */ --brand-50: color-mix(in oklch, var(--brand) 5%, white); --brand-100: color-mix(in oklch, var(--brand) 10%, white); --brand-200: color-mix(in oklch, var(--brand) 25%, white); --brand-300: color-mix(in oklch, var(--brand) 40%, white); --brand-400: color-mix(in oklch, var(--brand) 60%, white); /* Base */ --brand-500: var(--brand); /* Shades (darker) — mixing with black */ --brand-600: color-mix(in oklch, var(--brand) 80%, black); --brand-700: color-mix(in oklch, var(--brand) 60%, black); --brand-800: color-mix(in oklch, var(--brand) 40%, black); --brand-900: color-mix(in oklch, var(--brand) 25%, black); } ``` Tints (mixing with white) 10% 25% 50% 75% Base Shades (mixing with black) Base 75% 50% 25% 10% Semi-transparent variants (mixing with transparent) 10% 25% 50% 75% 100% `} css={`.mix-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.25rem; } .section h3 { font-size: 0.8rem; color: #555; margin: 0 0 0.5rem; font-weight: 600; } .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; } .chip { height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; } .chip span { font-size: 0.75rem; font-weight: 600; color: #333; } .chip[style*="color: white"] span { color: white; } .checkerboard { background-image: repeating-conic-gradient(#e0e0e0 0% 25%, white 0% 50%); background-size: 16px 16px; padding: 0.5rem; border-radius: 8px; }`} height={320} /> ### Transparent Variants Mixing with `transparent` creates semi-transparent versions of any color, which is especially useful for hover states, overlays, and backgrounds: ```css :root { --brand: oklch(55% 0.25 264); /* Semi-transparent variants */ --brand-alpha-10: color-mix(in oklch, var(--brand) 10%, transparent); --brand-alpha-20: color-mix(in oklch, var(--brand) 20%, transparent); --brand-alpha-50: color-mix(in oklch, var(--brand) 50%, transparent); } .hover-card:hover { background-color: var(--brand-alpha-10); } .overlay { background-color: var(--brand-alpha-50); } .subtle-border { border-color: var(--brand-alpha-20); } ``` ### Interactive State Colors ```css :root { --btn-bg: oklch(55% 0.22 264); --btn-hover: color-mix(in oklch, var(--btn-bg), white 15%); --btn-active: color-mix(in oklch, var(--btn-bg), black 15%); --btn-disabled: color-mix(in oklch, var(--btn-bg) 40%, oklch(70% 0 0)); } .button { background: var(--btn-bg); } .button:hover { background: var(--btn-hover); } .button:active { background: var(--btn-active); } .button:disabled { background: var(--btn-disabled); } ``` ### Color Space Comparison ```css :root { --red: oklch(60% 0.25 30); --blue: oklch(55% 0.25 264); /* sRGB interpolation — can produce muddy, desaturated results */ --mix-srgb: color-mix(in srgb, var(--red), var(--blue)); /* oklch interpolation — maintains vibrancy through the blend */ --mix-oklch: color-mix(in oklch, var(--red), var(--blue)); } ``` ### Dynamic Theme with a Single Base Color ```css :root { --base: oklch(55% 0.2 264); --surface: color-mix(in oklch, var(--base) 5%, white); --surface-raised: color-mix(in oklch, var(--base) 10%, white); --border: color-mix(in oklch, var(--base) 20%, oklch(80% 0 0)); --text: color-mix(in oklch, var(--base) 40%, black); --text-muted: color-mix(in oklch, var(--base) 30%, oklch(50% 0 0)); --accent: var(--base); --accent-hover: color-mix(in oklch, var(--base), white 20%); } ``` ### Blending Adjacent Color Tokens ```css :root { --success: oklch(60% 0.2 145); --warning: oklch(70% 0.2 85); /* Blend between semantic colors for status transitions */ --status-improving: color-mix(in oklch, var(--warning) 60%, var(--success)); } ``` ## Common AI Mistakes - Hard-coding every color shade as a separate hex value instead of deriving tints and shades from a single base with `color-mix()` - Using `in srgb` for blending when `in oklch` or `in oklab` produces more visually uniform results — sRGB blending creates muddy midpoints especially between saturated colors - Omitting the color space parameter entirely — it is required by the specification - Confusing the percentage semantics: `color-mix(in oklch, red 70%, blue)` means 70% red and 30% blue, not adding 70% of blue - Reaching for JavaScript or CSS preprocessors (`sass darken()`, `lighten()`) for color manipulation that `color-mix()` handles natively - Not realizing that mixing with `transparent` creates a semi-transparent version of the color — AI agents often still use `rgba()` or manual alpha values - Using `color-mix()` where a simpler `oklch()` value with adjusted lightness would be clearer and more maintainable ## When to Use - **Design systems**: Derive an entire shade scale from a single brand color - **Interactive states**: Generate hover, active, focus, and disabled color variants systematically - **Transparent overlays**: Create alpha variants of semantic colors without hard-coding rgba values - **Theme customization**: Let users pick a base color and derive all UI colors from it - **Blending semantic tokens**: Mix between success/warning/danger colors for status transitions ### When not to use - For simple static colors that don't need to relate to a base — just use `oklch()` or `hex` directly - When every shade needs precise, designer-specified values that don't follow a simple mix with white/black - For critical branded colors that must match exact specifications (mix results depend on the interpolation color space) ## References - [MDN: color-mix()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/color-mix) - [CSS color-mix(): The Complete Guide — DevToolbox](https://devtoolbox.dedyn.io/blog/css-color-mix-complete-guide) - [Using color-mix() to create opacity variants — Una Kravets](https://una.im/color-mix-opacity/) - [Quick and Dirty Colour Palettes using color-mix() — Always Twisted](https://www.alwaystwisted.com/articles/quick-and-dirty-colour-palettes-using-color-mix) - [A deep dive into the CSS color-mix() function — DEV Community](https://dev.to/astrit/a-deep-dive-into-the-css-color-mix-function-and-future-of-colors-on-the-web-2pgi) --- # Clip-Path and Mask > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/clip-path-and-mask ## The Problem AI agents almost never use `clip-path` or CSS masks, defaulting to rectangular layouts even when the design clearly calls for angled edges, circular reveals, or faded borders. When they do attempt non-rectangular shapes, they reach for images, SVGs, or extra wrapper divs with `overflow: hidden` and rotated pseudo-elements — all far more complex and brittle than the native CSS solutions. ## The Solution CSS provides two complementary tools for non-rectangular rendering: - **`clip-path`** — Hard-edge clipping using geometric shape functions (`polygon()`, `circle()`, `ellipse()`, `inset()`) or SVG paths. Content outside the clip is invisible and non-interactive. - **`mask-image`** — Soft-edge masking using images or gradients. Where the mask is black (or opaque), the element shows through; where it is transparent, the element is hidden. Gradients create smooth fade effects. ## Code Examples ### Clip-Path: Basic Shapes ```css /* Circle clip */ .avatar-circle { clip-path: circle(50%); } /* Ellipse */ .banner-ellipse { clip-path: ellipse(60% 40% at 50% 50%); } /* Inset with rounded corners */ .rounded-inset { clip-path: inset(10px round 16px); } ``` ### Clip-Path: Polygon Shapes ```css /* Triangle */ .triangle { clip-path: polygon(50% 0%, 0% 100%, 100% 100%); } /* Angled section edge */ .angled-section { clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%); } /* Chevron / arrow */ .chevron { clip-path: polygon( 0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%, 25% 50% ); } /* Hexagon */ .hexagon { clip-path: polygon( 25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50% ); } ``` ### Angled Hero Section ```css .hero { background: linear-gradient(135deg, #1a1a2e, #16213e); padding: 80px 24px 120px; clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%); } .next-section { margin-top: -60px; /* overlap into the angled area */ position: relative; z-index: 1; } ``` ```html Angled Hero Content overlaps the angled edge. ``` ### Animating Clip-Path Clip-path shapes can be transitioned and animated as long as the shape function type and point count stay the same. ```css .reveal-circle { clip-path: circle(0% at 50% 50%); transition: clip-path 0.6s ease-out; } .reveal-circle.is-visible { clip-path: circle(75% at 50% 50%); } ``` ```css /* Morphing between two polygons with the same number of points */ .morph { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); /* diamond */ transition: clip-path 0.4s ease; } .morph:hover { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); /* rectangle */ } ``` ### CSS Mask: Gradient Fade Masks use luminance or alpha to determine visibility. A gradient from opaque to transparent creates a smooth fade. ```css /* Fade out at the bottom */ .fade-bottom { mask-image: linear-gradient(to bottom, black 60%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); } /* Fade both edges horizontally */ .fade-edges { mask-image: linear-gradient( to right, transparent, black 15%, black 85%, transparent ); -webkit-mask-image: linear-gradient( to right, transparent, black 15%, black 85%, transparent ); } ``` ```html ``` ### Scrollable Container with Faded Edges ```css .scroll-fade { overflow-x: auto; mask-image: linear-gradient( to right, transparent, black 40px, black calc(100% - 40px), transparent ); -webkit-mask-image: linear-gradient( to right, transparent, black 40px, black calc(100% - 40px), transparent ); } ``` ### Mask with Radial Gradient (Spotlight Effect) ```css .spotlight { mask-image: radial-gradient( circle at var(--x, 50%) var(--y, 50%), black 0%, black 20%, transparent 60% ); -webkit-mask-image: radial-gradient( circle at var(--x, 50%) var(--y, 50%), black 0%, black 20%, transparent 60% ); } ``` Pair with JavaScript to move `--x` and `--y` custom properties on `mousemove` for an interactive spotlight. ### Combining Clip-Path and Mask ```css /* Hard clip for overall shape, soft mask for edge fading */ .shaped-fade { clip-path: polygon(0 0, 100% 0, 100% 80%, 50% 100%, 0 80%); mask-image: linear-gradient(to bottom, black 70%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); } ``` ### Mask with SVG Image ```css .masked-image { mask-image: url("mask-shape.svg"); mask-size: contain; mask-repeat: no-repeat; mask-position: center; -webkit-mask-image: url("mask-shape.svg"); -webkit-mask-size: contain; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; } ``` ## Live Previews Angled Hero Sectionclip-path: polygon() creates the diagonal edgeContent flows beneath the angled edge.`} css={` .wrapper { width: 100%; height: 100%; font-family: system-ui, sans-serif; } .hero { background: linear-gradient(135deg, #1e3a5f, #3b82f6); padding: 40px 24px 80px; clip-path: polygon(0 0, 100% 0, 100% 75%, 0 100%); color: white; } .hero h1 { margin: 0 0 8px; font-size: 24px; } .hero p { margin: 0; font-size: 14px; opacity: 0.85; } .content { margin-top: -30px; padding: 0 24px 24px; position: relative; z-index: 1; } .content p { margin: 0; font-size: 14px; color: #334155; } `} height={260} /> `} css={` .demo { display: flex; gap: 24px; justify-content: center; align-items: center; height: 100%; background: #0f172a; padding: 24px; } .circle-clip { width: 120px; height: 120px; background: linear-gradient(135deg, #3b82f6, #ec4899); clip-path: circle(50%); } .hex { background: linear-gradient(135deg, #8b5cf6, #06b6d4); clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); } .diamond { background: linear-gradient(135deg, #f59e0b, #ef4444); clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); } `} height={220} /> This content gradually fades out at the bottom using a CSS mask with a linear gradient. The mask transitions from fully opaque (black) to transparent, creating a smooth fade effect without any images.This second paragraph is partially hidden by the fade, demonstrating how mask-image works with gradient values.`} css={` .demo { display: flex; justify-content: center; align-items: flex-start; height: 100%; background: #0f172a; padding: 24px; font-family: system-ui, sans-serif; } .fade-box { background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 12px; padding: 24px; max-width: 360px; color: white; mask-image: linear-gradient(to bottom, black 40%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%); } .fade-box p { margin: 0 0 12px; font-size: 14px; line-height: 1.6; } .fade-box p:last-child { margin-bottom: 0; } `} height={240} /> ## Common AI Mistakes - **Never using clip-path at all** — Defaulting to rectangular layouts and ignoring the design's call for angled, circular, or geometric section edges. - **Using rotated pseudo-elements instead of clip-path** — Creating angled edges with `transform: rotate()` on `::before`/`::after` and `overflow: hidden`, which is fragile and harder to maintain. - **Animating between different shape functions** — `clip-path` transitions only work when the start and end values use the same function (e.g., both `polygon()`) with the same number of points. - **Forgetting the `-webkit-` prefix on mask properties** — Safari requires `-webkit-mask-image`, `-webkit-mask-size`, etc. Without these, masks are invisible in Safari. - **Using `mask-image` with `mask` shorthand incorrectly** — The `mask` shorthand has complex sub-property parsing. Use individual properties (`mask-image`, `mask-size`, `mask-repeat`) for clarity and reliability. - **Forgetting that clipped areas lose interactivity** — Content outside the `clip-path` region is not just invisible but also non-clickable and non-hoverable, which can break expected interaction areas. - **Using images for simple geometric masks** — Loading an external image for a shape that `clip-path: polygon()` or a gradient mask can express natively. ## When to Use - **Angled section dividers** — Hero sections, feature blocks, and footer edges with diagonal or curved cuts - **Circular or geometric image crops** — Avatars, thumbnails, and decorative image shapes without extra markup - **Fade-out effects** — Scrollable containers, image reveals, and content previews that fade at edges - **Page transition animations** — Circle-wipe or polygon-morph reveals for route changes - **Decorative UI shapes** — Hexagonal cards, diamond badges, arrow callouts, and non-rectangular layouts ## References - [clip-path — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path) - [mask-image — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/mask-image) - [Clipping and Masking in CSS — CSS-Tricks](https://css-tricks.com/clipping-masking-css/) - [The Modern Guide for Making CSS Shapes — Smashing Magazine](https://www.smashingmagazine.com/2024/05/modern-guide-making-css-shapes/) - [CSS Masking — Ahmad Shadeed](https://ishadeed.com/article/css-masking/) - [Fade Out Overflow Using CSS Mask-Image — PQINA](https://pqina.nl/blog/fade-out-overflow-using-css-mask-image/) --- # Border Techniques > Source: https://takazudomodular.com/pj/zcss/docs/styling/shadows-and-borders/border-techniques ## The Problem AI agents typically stick to `border: 1px solid #ccc` and rarely explore the richer border capabilities CSS offers. Gradient borders, double-border effects, outline tricks, and the interplay between `border-radius` and `overflow: hidden` are routinely missed or implemented incorrectly. The biggest pitfall is using `border-image` with `border-radius` — they are incompatible, and AI agents generate broken code when combining them. ## The Solution CSS provides multiple properties for border effects beyond the basic `border` shorthand. Understanding when to use `border-image`, `outline`, `box-shadow`, and background-based gradient border techniques prevents common compatibility issues. ## Code Examples ### Gradient Borders with border-image The simplest syntax for a gradient border. Works for straight-edged elements only. ```css .gradient-border-straight { border: 4px solid; border-image: linear-gradient(135deg, #3b82f6, #8b5cf6) 1; } ``` The `border-image-slice: 1` (the trailing `1`) tells the browser to use the entire gradient image as the border fill. ### Gradient Borders with border-radius (Background-Clip Approach) `border-image` does not work with `border-radius`. Use the background-clip technique instead. ```css .gradient-border-rounded { border: 3px solid transparent; border-radius: 12px; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box; } ``` The first background fills the padding-box with solid white (or the desired inner color), and the second fills the border-box with the gradient. The transparent border reveals the gradient beneath. ```html Content with a rounded gradient border. ``` ### Gradient Border with Custom Background Color ```css /* Works on any background color */ .gradient-border-dark { --bg-color: #1a1a2e; border: 2px solid transparent; border-radius: 8px; background: linear-gradient(var(--bg-color), var(--bg-color)) padding-box, linear-gradient(135deg, #3b82f6, #ec4899) border-box; } ``` ### Outline vs Border `outline` does not affect layout, does not respect `border-radius` (in older browsers), and is drawn outside the border edge. Modern browsers do follow `border-radius` for outlines. ```css /* Outline for focus indicators — does not shift layout */ .input-focus:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } /* Border changes shift layout unless using box-sizing carefully */ .input-border:focus { border: 2px solid #3b82f6; } ``` ### Outline-Offset for Spaced Rings `outline-offset` creates a gap between the element and its outline, useful for focus indicators and decorative rings. ```css .ring-effect { border: 2px solid #3b82f6; outline: 2px solid #3b82f6; outline-offset: 4px; border-radius: 8px; } ``` ### Double Borders with box-shadow `box-shadow` can simulate additional borders because `inset` shadows follow `border-radius` and don't affect layout. ```css /* Double border effect */ .double-border { border: 2px solid #3b82f6; border-radius: 8px; box-shadow: 0 0 0 4px white, 0 0 0 6px #3b82f6; } /* Triple ring effect */ .triple-ring { border-radius: 50%; box-shadow: 0 0 0 4px #3b82f6, 0 0 0 8px white, 0 0 0 12px #8b5cf6; } ``` ### Inset Shadow as Inner Border ```css .inner-border { border-radius: 12px; box-shadow: inset 0 0 0 2px #3b82f6; } ``` This creates a border inside the element without changing its outer dimensions. ### border-radius and overflow: hidden Gotchas When using `border-radius` with children that have their own backgrounds, the children's corners poke through unless `overflow: hidden` is set on the parent. ```css /* Without overflow: hidden — child corners poke through */ .card-broken { border-radius: 12px; border: 1px solid #e2e8f0; } .card-broken img { width: 100%; /* Image corners are square, poking outside the rounded card */ } /* Fixed with overflow: hidden */ .card-fixed { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; } .card-fixed img { width: 100%; /* Image corners are clipped to the card's border-radius */ } ``` ```html Card Title ``` **Gotcha**: `overflow: hidden` also clips box-shadows and any content that extends beyond the element's bounds (tooltips, dropdowns). Use it deliberately. ### Border-Radius with Background-Clip for Padding Effect ```css /* Visible gap between border and content using background-clip */ .padded-border { border: 4px solid #3b82f6; border-radius: 12px; padding: 4px; background: #3b82f6; background-clip: content-box; } ``` ## Live Previews Gradient border using border-image. Works on straight edges only — no border-radius.`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .gradient-border-straight { border: 4px solid; border-image: linear-gradient(135deg, #3b82f6, #8b5cf6) 1; padding: 24px; max-width: 320px; } .gradient-border-straight p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Rounded gradient border using the background-clip technique.`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .gradient-border-rounded { border: 3px solid transparent; border-radius: 16px; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #ec4899) border-box; padding: 24px; max-width: 320px; } .gradient-border-rounded p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Double border using box-shadow spread`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 32px; font-family: system-ui, sans-serif; } .double-border { border: 2px solid #3b82f6; border-radius: 12px; box-shadow: 0 0 0 5px white, 0 0 0 7px #8b5cf6; padding: 24px; background: white; max-width: 320px; } .double-border p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Ring effect with outline-offset`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 32px; font-family: system-ui, sans-serif; } .ring-box { border: 2px solid #3b82f6; outline: 2px solid #8b5cf6; outline-offset: 4px; border-radius: 12px; padding: 24px; background: white; max-width: 320px; } .ring-box p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> ## Common AI Mistakes - **Combining border-image with border-radius** — This is the most common mistake. `border-image` ignores `border-radius` entirely, producing square corners despite the radius declaration. - **Using border for focus indicators** — Border changes shift layout. `outline` with `outline-offset` is the correct approach for focus states. - **Forgetting overflow: hidden on rounded containers** — Child images and colored sections poke through rounded corners without it. - **Not using box-shadow for multi-ring effects** — AI agents try to nest extra divs for decorative borders when `box-shadow` spread with zero blur handles it cleanly. - **Misunderstanding border-image-slice** — Forgetting the `1` slice value when using gradients, resulting in an empty border. - **Using a complex wrapper div for gradient borders** — The `background-clip: padding-box, border-box` technique eliminates extra markup. ## When to Use - **Gradient borders** — Feature cards, highlighted sections, CTAs that need visual distinction - **outline + outline-offset** — Accessible focus indicators that don't shift layout - **box-shadow rings** — Avatar rings, status indicators, decorative multi-border effects - **Inset box-shadow** — Inner borders that don't change outer dimensions - **overflow: hidden on rounded containers** — Any card or container with border-radius that holds images or colored child elements ## Tailwind CSS Tailwind provides `border-*`, `ring-*`, `outline-*`, and `divide-*` utilities for border effects. The `ring-*` utilities are particularly useful for focus indicators and decorative ring effects without layout shift. ### Border and Ring Effects border-2 ring-2 ring + offset outline + offset `} height={240} /> ### Divide Utilities First item Second item Third item Fourth item `} height={220} /> ### Focus Ring Pattern Focused Button Outlined Focus `} height={180} /> ## References - [Gradient Borders in CSS — CSS-Tricks](https://css-tricks.com/gradient-borders-in-css/) - [border-image — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/border-image) - [Border with Gradient and Radius — Temani Afif](https://dev.to/afif/border-with-gradient-and-radius-387f) - [outline — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/outline) - [outline-offset — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/outline-offset) --- # Three-Tier Font-Size Strategy > Source: https://takazudomodular.com/pj/zcss/docs/typography/font-sizing/three-tier-font-size-strategy ## The Problem When building a UI, it's tempting to use font-size values directly — `font-size: 1.25rem` in one component, `font-size: 20px` in another, and `font-size: 1.3rem` in a third, all meaning "slightly large text." This scatters raw values across the codebase with no central point of control. A common first improvement is defining **semantic tokens**: `--font-heading`, `--font-body`, `--font-caption`. But this mixes two concerns into one layer — the raw size value and its semantic role. If headings should shrink from 28px to 24px, you change the token. But the nav links that also used `--font-heading` (because it happened to be the right size) shrink too, unintentionally. The opposite approach — using only an **abstract scale** like `text-lg` — avoids the role-locking problem but loses semantic clarity. Developers see `text-lg` scattered across the codebase and have to figure out whether it's a heading, a subtitle, or just emphasized text. Neither approach alone is enough. What's needed is a separation between _how big_ (the raw value) and _what for_ (the semantic role). ## The Solution Organize font sizes into **three tiers**, each with a clear purpose: | Tier | Name | Purpose | Example | | --- | --- | --- | --- | | 1 | **Scale** | Abstract size values — the available steps | `--scale-lg` → `1.25rem` | | 2 | **Theme** | Semantic roles — what each size _means_ | `--font-heading` → `var(--scale-xl)` | | 3 | **Component** | Scoped overrides — sizes for one component | `--_card-title` → `var(--font-subheading)` | The key insight: **each tier only references the tier above it**. Components use theme tokens. Theme tokens point to scale values. Scale holds the actual rem/px values. This is the same architecture as the [Three-Tier Color Strategy](../../styling/color/three-tier-color-strategy/) (palette → theme → component), applied to font sizes. ## Code Examples ### Tier 1: The Scale The scale is the raw material — every font size available in the system. These values are not used directly in components. Think of them as paint tubes: you have them ready, but you don't squeeze them onto the canvas without a plan. Scale (Tier 1) These are the raw sizes. Components should not use these directly. Aa --scale-2xl 2.5rem (40px) Aa --scale-xl 1.75rem (28px) Aa --scale-lg 1.25rem (20px) Aa --scale-base 1rem (16px) Aa --scale-sm 0.875rem (14px) Aa --scale-xs 0.75rem (12px) `} css={`.scale-demo { padding: 1.25rem; font-family: system-ui, sans-serif; background: hsl(0 0% 99%); height: 100%; box-sizing: border-box; } .scale-demo__title { margin: 0 0 0.25rem; font-size: 0.85rem; font-weight: 700; color: hsl(222 47% 11%); } .scale-demo__desc { margin: 0 0 0.75rem; font-size: 0.72rem; color: hsl(215 16% 47%); } .scale-demo__rows { display: flex; flex-direction: column; gap: 6px; } .scale-demo__row { display: flex; align-items: center; gap: 12px; } .scale-demo__sample { width: 56px; flex-shrink: 0; font-weight: 700; color: hsl(222 47% 11%); text-align: right; } .scale-demo__info { display: flex; flex-direction: column; gap: 1px; } .scale-demo__name { font-size: 0.72rem; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .scale-demo__value { font-size: 0.65rem; color: hsl(215 16% 47%); }`} /> In a Tailwind project, Tier 1 lives in the `@theme` block: ```css @theme { --font-size-xs: 0.75rem; /* 12px */ --font-size-sm: 0.875rem; /* 14px */ --font-size-base: 1rem; /* 16px */ --font-size-lg: 1.25rem; /* 20px */ --font-size-xl: 1.75rem; /* 28px */ --font-size-2xl: 2.5rem; /* 40px */ } ``` For detailed Tailwind `@theme` configuration, including paired line-heights and weight tokens, see [Typography Token Patterns](../../methodology/tight-token-strategy/typography-tokens/). ### Tier 2: The Theme Theme tokens give **semantic meaning** to scale values. Instead of "lg", components see "subheading" or "heading". This is the layer that makes typography adjustments painless — change `--font-heading` from `--scale-xl` to `--scale-lg` in one place, and every heading in the project updates. Scale → Theme Mapping --scale-2xl → --font-display --scale-xl → --font-heading --scale-lg → --font-subheading --scale-base → --font-body --scale-sm → --font-secondary --scale-xs → --font-caption Result: UI using Theme tokens App Dashboard Welcome back Your project overview All systems operational. Last deploy was 12 minutes ago with no issues reported. Updated 2 min ago View Details `} css={`:root { /* Tier 1: Scale */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; /* Tier 2: Theme — semantic pointers to scale */ --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .theme-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: hsl(0 0% 99%); height: 100%; box-sizing: border-box; } .theme-demo__title { margin: 0 0 0.6rem; font-size: 0.72rem; font-weight: 700; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.04em; } .theme-demo__col { min-width: 0; } .mapping { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; } .mapping__from { font-size: 0.72rem; font-family: monospace; color: hsl(215 16% 47%); width: 90px; text-align: right; flex-shrink: 0; } .mapping__arrow { font-size: 0.75rem; color: hsl(215 16% 47%); } .mapping__to { font-size: 0.72rem; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; } .mini-ui { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .mini-ui__header { background: hsl(222 47% 11%); color: hsl(0 0% 100%); padding: 0.4rem 0.75rem; display: flex; align-items: center; gap: 0.75rem; } .mini-ui__logo { font-size: var(--font-secondary); font-weight: 700; } .mini-ui__nav { font-size: var(--font-caption); color: hsl(215 25% 70%); } .mini-ui__body { padding: 0.75rem; color: hsl(222 47% 11%); } .mini-ui__heading { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; margin-bottom: 0.15rem; } .mini-ui__subheading { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); margin-bottom: 0.5rem; } .mini-ui__text { font-size: var(--font-body); line-height: 1.6; color: hsl(222 47% 11%); margin-bottom: 0.5rem; } .mini-ui__footer { display: flex; align-items: center; justify-content: space-between; padding-top: 0.4rem; border-top: 1px solid hsl(214 32% 91%); } .mini-ui__caption { font-size: var(--font-caption); color: hsl(215 16% 47%); } .mini-ui__btn { font-size: var(--font-caption); font-weight: 600; background: hsl(221 83% 53%); color: hsl(0 0% 100%); border: none; border-radius: 6px; padding: 0.3rem 0.6rem; cursor: pointer; }`} /> In CSS, Tier 2 lives on `:root` (or any shared scope): ```css :root { --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } ``` ### Tier 3: Component-Scoped Sizes Sometimes a component needs font-size decisions that don't fit into the global theme — a compact sidebar with smaller text, a pricing card with an oversized amount, or an admin panel with denser typography. These are **Tier 3** variables: narrowly scoped, defined on the component itself, and referencing theme or scale tokens. Use a leading underscore (`--_`) for component-scoped custom properties to signal local scope: ```css .pricing-card { --_card-amount: var(--scale-2xl); --_card-period: var(--font-caption); } ``` The `--_` prefix tells readers "this variable is locally scoped to this component" — similar to how `_privateMethod` signals private scope in other languages. This is not a CSS rule — it is a project-level naming convention. In Tailwind + component-first projects (React, Vue, Astro with utility classes), Tier 3 component-scoped CSS custom properties are rarely needed. The component framework itself provides scoping — there are no separate CSS files where these variables would be defined. Tier 3 is primarily relevant for general CSS approaches (BEM, CSS Modules, vanilla CSS). Standard $29 per month Perfect for small teams getting started with design tokens. Premium $99 per month For teams that need advanced theming and component controls. Navigation Main Dashboard Projects Settings Account Billing `} css={`:root { --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .tier3-demo { display: flex; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; background: hsl(210 40% 96%); height: 100%; box-sizing: border-box; color: hsl(222 47% 11%); } /* Tier 3: Pricing card — local size variables */ .pricing-card { --_card-amount: var(--scale-2xl); --_card-period: var(--font-caption); --_card-desc: var(--font-secondary); --_card-badge: var(--font-caption); flex: 1; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .pricing-card--premium { --_card-amount: var(--scale-2xl); border-color: hsl(221 83% 53%); } .pricing-card__badge { font-size: var(--_card-badge); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; background: hsl(210 40% 96%); color: hsl(215 16% 47%); } .pricing-card--premium .pricing-card__badge { background: hsl(221 83% 53%); color: hsl(0 0% 100%); } .pricing-card__body { padding: 0.75rem; } .pricing-card__amount { font-size: var(--_card-amount); font-weight: 700; line-height: 1.1; } .pricing-card__period { font-size: var(--_card-period); color: hsl(215 16% 47%); margin-bottom: 0.4rem; } .pricing-card__desc { font-size: var(--_card-desc); color: hsl(215 16% 47%); line-height: 1.5; } /* Tier 3: Sidebar nav — compact local sizes */ .sidebar-nav { --_nav-title: var(--font-secondary); --_nav-category: var(--font-caption); --_nav-link: var(--font-secondary); width: 140px; flex-shrink: 0; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; padding: 0.6rem; } .sidebar-nav__title { font-size: var(--_nav-title); font-weight: 700; margin-bottom: 0.5rem; } .sidebar-nav__category { font-size: var(--_nav-category); font-weight: 600; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.04em; margin: 0.4rem 0 0.2rem; } .sidebar-nav__link { display: block; font-size: var(--_nav-link); color: hsl(221 83% 53%); padding: 0.15rem 0; text-decoration: none; cursor: pointer; }`} /> Notice how each component defines its own font-size variables but still references the theme or scale tiers. The pricing card uses `--_card-amount: var(--scale-2xl)` (scale) and `--_card-desc: var(--font-secondary)` (theme). The sidebar nav references theme tokens for all its sizes. If the global type scale changes, these components update automatically. ### All Three Tiers Working Together This demo shows a complete page layout with all three tiers visible in the CSS. Tier 1 defines the raw scale, Tier 2 maps it to semantic roles, and Tier 3 gives the stat cards their own local size variables. AppName Docs Blog About Project Dashboard Overview of your active deployments All systems are running normally. The latest deployment completed successfully 12 minutes ago with zero errors across 3 services. 12 Projects 98% Uptime 3 Deploys Last updated 2 minutes ago `} css={`:root { /* ── Tier 1: Scale ── */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; /* ── Tier 2: Theme ── */ --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .page { font-family: system-ui, sans-serif; color: hsl(222 47% 11%); height: 100%; display: flex; flex-direction: column; } .page__header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background: hsl(222 47% 11%); color: hsl(0 0% 100%); } .page__logo { font-size: var(--font-body); font-weight: 700; } .page__nav { display: flex; gap: 1rem; } .page__nav-link { font-size: var(--font-caption); color: hsl(215 25% 70%); text-decoration: none; cursor: pointer; } .page__main { padding: 1.25rem 1rem; flex: 1; } .page__title { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; margin: 0 0 0.15rem; } .page__subtitle { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); margin: 0 0 0.6rem; } .page__body { font-size: var(--font-body); line-height: 1.6; margin: 0 0 1rem; max-width: 560px; } .page__timestamp { display: block; font-size: var(--font-caption); color: hsl(215 16% 47%); margin-top: 0.75rem; } /* ── Tier 3: Stat card ── */ .stat-grid { display: flex; gap: 0.75rem; } .stat-card { --_stat-value: var(--scale-xl); --_stat-label: var(--font-caption); background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem 1rem; text-align: center; min-width: 80px; } .stat-card__value { display: block; font-size: var(--_stat-value); font-weight: 700; line-height: 1.2; } .stat-card__label { display: block; font-size: var(--_stat-label); color: hsl(215 16% 47%); }`} /> ### The Power of Tier 2: Swapping Size Themes The most powerful feature of this architecture: **the same markup works with completely different size themes**. Just remap Tier 2 — the semantic tokens — and the entire UI adjusts. This is how you implement compact mode, large/accessible mode, or density settings without touching any component CSS. Default Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View Compact Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View Large Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View `} css={`:root { /* Tier 1: Scale — same for all themes */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; } /* Default Tier 2 mapping */ .theme-col--default { --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } /* Compact Tier 2 — every role shifts one step down */ .theme-col--compact { --font-heading: var(--scale-lg); --font-subheading: var(--scale-base); --font-body: var(--scale-sm); --font-secondary: var(--scale-xs); --font-caption: var(--scale-xs); } /* Large Tier 2 — every role shifts one step up */ .theme-col--large { --font-heading: var(--scale-2xl); --font-subheading: var(--scale-xl); --font-body: var(--scale-lg); --font-secondary: var(--scale-base); --font-caption: var(--scale-sm); } .themes-demo { display: grid; grid-template-columns: 1fr 1fr 1fr; height: 100%; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .theme-col { background: hsl(210 40% 96%); padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; } .theme-col + .theme-col { border-left: 1px solid hsl(214 32% 91%); } .theme-col__label { margin: 0; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .card { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; } .card__heading { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; } .card__subheading { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); } .card__body { font-size: var(--font-body); line-height: 1.6; color: hsl(222 47% 11%); flex: 1; } .card__footer { display: flex; align-items: center; justify-content: space-between; padding-top: 0.4rem; border-top: 1px solid hsl(214 32% 91%); margin-top: 0.25rem; } .card__caption { font-size: var(--font-caption); color: hsl(215 16% 47%); } .card__btn { font-size: var(--font-caption); font-weight: 600; background: hsl(221 83% 53%); color: hsl(0 0% 100%); border: none; border-radius: 6px; padding: 0.25rem 0.5rem; cursor: pointer; }`} /> The markup is identical across all three columns. Only the Tier 2 mapping changes: - **Default**: heading → xl, body → base, caption → xs - **Compact**: heading → lg, body → sm, caption → xs (everything shifts down one step) - **Large**: heading → 2xl, body → lg, caption → sm (everything shifts up one step) Tier 1 and Tier 3 stay exactly the same — only Tier 2 changes. ### Complete CSS Code Structure Here is how the three tiers fit together in a real project: ```css /* ── Tier 1: Scale ── */ /* In Tailwind, this goes in @theme */ :root { --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; } /* ── Tier 2: Theme ── */ /* Semantic roles — change these to adjust the entire UI */ :root { --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } /* ── Tier 3: Component ── */ /* Scoped overrides — only when a component needs its own size logic */ .pricing-card { --_card-amount: var(--scale-2xl); --_card-label: var(--font-caption); } .sidebar-nav { --_nav-link: var(--font-secondary); --_nav-category: var(--font-caption); } ``` ### Tailwind CSS Integration In a Tailwind v4 project, the three tiers map naturally to existing patterns: **Tier 1** → Tailwind's `@theme` block. This is where you define the constrained scale: ```css @theme { --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.25rem; --font-size-xl: 1.75rem; --font-size-2xl: 2.5rem; } ``` This gives you `text-xs` through `text-2xl` utilities. For the full Tailwind configuration including paired line-heights and weight tokens, see [Typography Token Patterns](../../methodology/tight-token-strategy/typography-tokens/). **Tier 2** → CSS custom properties on `:root`. These are not Tailwind utilities — they are semantic tokens used in your CSS: ```css :root { --font-heading: var(--font-size-xl); --font-subheading: var(--font-size-lg); --font-body: var(--font-size-base); --font-secondary: var(--font-size-sm); --font-caption: var(--font-size-xs); } ``` Components reference these in their CSS: `font-size: var(--font-heading)`. **Tier 3** → Component-scoped CSS custom properties, same as the general pattern. ## Common AI Mistakes - **Skipping Tier 2** — using scale values directly in components (`font-size: var(--scale-lg)` or `text-lg` everywhere) means there's no semantic layer; changing the heading size requires updating every component - **Using semantic names at Tier 1** — defining `--font-size-heading` in `@theme` locks the scale to specific roles; when you need the same size for a non-heading, the token name becomes misleading - **Not separating scale from theme** — defining `--font-heading: 1.75rem` with a hardcoded value means you can't adjust the entire scale proportionally; the heading size is disconnected from the rest of the type system - **Too many Tier 3 variables** — if a component defines 10+ local font-size variables, it's likely reinventing the theme layer; promote those to Tier 2 - **Making Tier 1 too small** — a scale with only 3 sizes forces components to invent their own raw values (Tier 3 variables with hardcoded rem values), breaking the system ## When to Use - **Any project with more than a few components** — the overhead of three tiers pays off as soon as you need consistent typography - **Multi-density or accessibility modes** — Tier 2 makes compact/spacious/accessible switching trivial - **Design system or component library** — components should reference theme tokens, not raw scale values - **Gradual adoption** — you can start with Tier 1 + 2 and add Tier 3 as components need scoped overrides ### When three tiers is overkill - Single-page sites with one type scale and no density variations - Quick prototypes where speed matters more than maintainability - Projects where only one developer touches the CSS ## Related Articles - [Typography Token Patterns](../../methodology/tight-token-strategy/typography-tokens/) — Practical `@theme` configuration for Tier 1 in Tailwind - [Three-Tier Color Strategy](../../styling/color/three-tier-color-strategy/) — The same three-tier architecture applied to colors - [Line Height Best Practices](../line-height-best-practices/) — Choosing line-heights to pair with your type scale - [Fluid Font Sizing](../fluid-font-sizing/) — Making Tier 1 values responsive with `clamp()` ## References - [MDN: Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # Variable Fonts > Source: https://takazudomodular.com/pj/zcss/docs/typography/fonts/variable-fonts ## The Problem Traditional web typography requires loading separate font files for each weight, width, and style combination. A typical project might load regular, bold, italic, and bold-italic variants — four files — just for body text. AI agents commonly generate CSS that references multiple static font weights (300, 400, 500, 600, 700) with separate `@font-face` declarations, resulting in five or more HTTP requests and significantly larger total download sizes. Variable fonts solve this by packing an entire range of variations into a single file. ## The Solution Variable fonts contain one or more **axes of variation** — continuous ranges for properties like weight, width, and slant. A single variable font file replaces multiple static files, reducing network requests and enabling smooth transitions between any values along those axes. The CSS `font-variation-settings` property provides low-level control, while standard CSS properties (`font-weight`, `font-stretch`, `font-style`) now accept ranges and map directly to registered axes. ### Registered Axes | Axis tag | CSS property | Description | Example range | | -------- | ----------------- | ------------------------------ | --------------- | | `wght` | `font-weight` | Weight (thin to black) | 100–900 | | `wdth` | `font-stretch` | Width (condensed to expanded) | 75%–125% | | `slnt` | `font-style` | Slant angle | -12deg–0deg | | `ital` | `font-style` | Italic (binary toggle) | 0 or 1 | | `opsz` | `font-optical-sizing` | Optical size adjustments | 8–144 | ## Code Examples ### Basic Variable Font Setup ```css @font-face { font-family: "Inter"; src: url("/fonts/Inter-Variable.woff2") format("woff2-variations"); font-weight: 100 900; /* Declare the full weight range */ font-display: swap; } body { font-family: "Inter", system-ui, sans-serif; } h1 { font-weight: 750; /* Any value in the range — not limited to 100-step increments */ } .light-text { font-weight: 350; } .bold-text { font-weight: 680; } ``` 100 ThinThe quick brown fox 250The quick brown fox 400 RegularThe quick brown fox 550The quick brown fox 700 BoldThe quick brown fox 850The quick brown fox 900 BlackThe quick brown fox `} css={`.vf-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 0.5rem; } .weight-row { display: flex; align-items: baseline; gap: 1rem; padding: 0.4rem 0.75rem; background: #f8f9fa; border-radius: 6px; } .label { font-size: 0.75rem; color: #6c63ff; font-weight: 600; min-width: 100px; flex-shrink: 0; } .sample { font-size: 1.2rem; color: #1a1a2e; }`} height={320} /> ### Using Standard CSS Properties (Preferred) ```css /* CORRECT: Use standard CSS properties for registered axes */ h1 { font-weight: 800; font-stretch: 110%; font-style: oblique 8deg; } /* AVOID: Low-level font-variation-settings for registered axes */ h1 { font-variation-settings: "wght" 800, "wdth" 110, "slnt" -8; } ``` Standard properties are preferred because they cascade properly, work with `inherit` and `initial`, and don't override each other. With `font-variation-settings`, setting one axis resets all others to their defaults. ### Custom Axes Custom axes (identified by uppercase tags) require `font-variation-settings`: ```css /* GRAD = Grade axis (custom), adjusts stroke weight without changing width */ .dark-bg-text { font-variation-settings: "GRAD" 150; } /* CASL = Casual axis in Recursive font */ .casual-text { font-variation-settings: "CASL" 1; } /* Combining custom axes with standard properties */ .display-text { font-weight: 700; font-variation-settings: "GRAD" 100, "CASL" 0.5; } ``` ### Responsive Weight with Custom Properties ```css :root { --heading-weight: 700; --body-weight: 400; } @media (max-width: 768px) { :root { --heading-weight: 600; /* Slightly lighter on small screens for readability */ --body-weight: 420; /* Slightly heavier for small screen legibility */ } } h1, h2, h3 { font-weight: var(--heading-weight); } body { font-weight: var(--body-weight); } ``` ### Animated Font Variations ```css .hover-weight { font-weight: 400; transition: font-weight 0.3s ease; } .hover-weight:hover { font-weight: 700; } /* Smooth weight animation — impossible with static fonts */ @keyframes breathe { 0%, 100% { font-weight: 300; } 50% { font-weight: 700; } } .animated-text { animation: breathe 3s ease-in-out infinite; } ``` ### Optical Sizing ```css /* Automatic optical sizing (on by default when the font supports it) */ body { font-optical-sizing: auto; } /* Manual control for specific cases */ .small-caption { font-size: 0.75rem; font-optical-sizing: auto; /* Font adjusts stroke contrast for small size */ } .display-hero { font-size: 4rem; font-optical-sizing: auto; /* Font adjusts for large display size */ } ``` ### Progressive Enhancement with @supports ```css /* Fallback: static font files */ @font-face { font-family: "MyFont"; src: url("/fonts/myfont-regular.woff2") format("woff2"); font-weight: 400; } @font-face { font-family: "MyFont"; src: url("/fonts/myfont-bold.woff2") format("woff2"); font-weight: 700; } /* Variable font override for supporting browsers */ @supports (font-variation-settings: normal) { @font-face { font-family: "MyFont"; src: url("/fonts/myfont-variable.woff2") format("woff2-variations"); font-weight: 100 900; } } ``` ### Dark Mode Weight Compensation ```css /* Text on dark backgrounds appears heavier — reduce weight to compensate */ @media (prefers-color-scheme: dark) { body { font-weight: 350; /* Lighter than the 400 used in light mode */ } h1 { font-weight: 650; /* Lighter than the 700 used in light mode */ } } ``` ## Common AI Mistakes - Loading multiple static font files (regular, medium, semibold, bold) instead of a single variable font file, multiplying HTTP requests unnecessarily - Using `font-variation-settings` for registered axes (weight, width, slant) instead of standard CSS properties — this breaks cascading and resets unspecified axes - Not declaring the weight range in `@font-face` (e.g., `font-weight: 100 900`), causing browsers to only use the default weight - Treating variable font weights like static fonts — only using values at 100-step increments (400, 500, 600) when any value in the range is valid - Not compensating for text appearing heavier on dark backgrounds — variable fonts make it easy to subtract 30–50 weight units for dark mode - Forgetting that `font-variation-settings` values all reset when you set any one axis — each declaration must include every axis you want to control - Using `format("woff2")` instead of `format("woff2-variations")` in the `@font-face` `src` descriptor, though most modern browsers accept either ## When to Use ### Variable fonts are ideal for - Projects using 3+ weights of the same font family — the single file is typically smaller than multiple static files - Designs that need fine-grained weight control (e.g., 350, 450, 550) - Animations or transitions involving weight, width, or slant changes - Dark mode designs where weight compensation improves readability - Responsive designs that adjust weight based on viewport size or context ### Stick with static fonts when - Only 1-2 weights are needed — a single static file may be smaller than the variable version - The chosen typeface is not available as a variable font - Legacy browser support is a hard requirement (IE11) ## References - [MDN: Variable fonts guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Fonts/Variable_fonts) - [MDN: font-variation-settings](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings) - [web.dev: Introduction to variable fonts](https://web.dev/articles/variable-fonts) - [Designing with Variable Fonts](https://variablefonts.io/about-variable-fonts/) - [Variable Fonts: Reduce Bloat And Fix Layout Shifts](https://inkbotdesign.com/variable-fonts/) - [A Variable Fonts Primer — Google Fonts](https://fonts.google.com/knowledge/introducing_type/introducing_variable_fonts) --- # Vertical Rhythm > Source: https://takazudomodular.com/pj/zcss/docs/typography/text-control/vertical-rhythm ## The Problem AI-generated layouts frequently look "off" despite using reasonable font sizes and colors. The root cause is inconsistent vertical spacing — margins, padding, and line-heights are set with arbitrary values that bear no mathematical relationship to each other. A heading with `margin-bottom: 12px` followed by a paragraph with `margin-top: 20px` followed by a list with `margin-bottom: 15px` creates visual noise. Humans perceive this inconsistency even without being able to articulate why the layout feels wrong. ## The Solution Vertical rhythm is the practice of keeping vertical spaces between elements consistent by deriving all spacing from a single base unit. This base unit is typically the body text's `line-height` computed value. All margins, padding, and gaps are then set to multiples (or fractions) of this base unit. ### The Core Principle 1. Define a base line-height (e.g., `1.5` on a `16px` font = `24px` rhythm unit) 2. Set all vertical spacing to multiples of that unit: `24px`, `48px`, `72px` (or `1×`, `2×`, `3×`) 3. Use fractional multiples for tighter spacing: `12px` (`0.5×`), `6px` (`0.25×`) ## Code Examples ### Basic Vertical Rhythm System ```css :root { --font-size-base: 1rem; /* 16px */ --line-height-base: 1.5; /* 24px rhythm unit */ --rhythm: calc(var(--font-size-base) * var(--line-height-base)); /* 1.5rem = 24px */ } body { font-size: var(--font-size-base); line-height: var(--line-height-base); } /* All spacing derived from the rhythm unit */ h1 { font-size: 2.5rem; line-height: 1.2; margin-block: calc(var(--rhythm) * 2) var(--rhythm); } h2 { font-size: 2rem; line-height: 1.25; margin-block: calc(var(--rhythm) * 2) var(--rhythm); } h3 { font-size: 1.5rem; line-height: 1.3; margin-block: var(--rhythm) calc(var(--rhythm) * 0.5); } p { margin-block-end: var(--rhythm); } ul, ol { margin-block-end: var(--rhythm); padding-inline-start: var(--rhythm); } li + li { margin-block-start: calc(var(--rhythm) * 0.25); } blockquote { margin-block: var(--rhythm); padding-block: calc(var(--rhythm) * 0.5); padding-inline-start: var(--rhythm); } ``` Consistent rhythm (24px base) Section Title First paragraph with body text. Vertical rhythm keeps spacing mathematically related to the base line-height unit. Second paragraph maintains the same spacing. Every gap is a multiple of the 24px rhythm unit. Another Section Content flows with predictable, harmonious spacing throughout the layout. Inconsistent spacing Section Title First paragraph with body text. The spacing here uses arbitrary values with no mathematical relationship. Second paragraph has different margin than the first. The gaps feel random and uneven. Another Section Content feels visually noisy even though the text is identical. `} css={`.vr-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .vr-column { background: #fafafa; border-radius: 8px; padding: 1rem; } .label { font-size: 0.8rem; color: #6c63ff; margin: 0 0 1rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } .consistent h2 { font-size: 1.4rem; line-height: 1.2; margin: 48px 0 24px; color: #1a1a2e; } .consistent h2:first-child { margin-top: 0; } .consistent p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 24px; color: #444; } .inconsistent h2 { font-size: 1.4rem; line-height: 1.2; margin: 35px 0 10px; color: #1a1a2e; } .inconsistent h2:first-child { margin-top: 0; } .inconsistent p { font-size: 0.95rem; line-height: 1.5; color: #444; } .inconsistent p:nth-child(2) { margin: 0 0 18px; } .inconsistent p:nth-child(3) { margin: 0 0 30px; } .inconsistent p:nth-child(5) { margin: 0 0 12px; }`} height={400} /> ### Spacing Scale Using the Rhythm Unit ```css :root { --rhythm: 1.5rem; /* 24px base unit */ --space-3xs: calc(var(--rhythm) * 0.125); /* 3px */ --space-2xs: calc(var(--rhythm) * 0.25); /* 6px */ --space-xs: calc(var(--rhythm) * 0.5); /* 12px */ --space-sm: calc(var(--rhythm) * 0.75); /* 18px */ --space-md: var(--rhythm); /* 24px */ --space-lg: calc(var(--rhythm) * 1.5); /* 36px */ --space-xl: calc(var(--rhythm) * 2); /* 48px */ --space-2xl: calc(var(--rhythm) * 3); /* 72px */ --space-3xl: calc(var(--rhythm) * 4); /* 96px */ } ``` ### Lobotomized Owl for Consistent Flow Spacing The "lobotomized owl" selector (`* + *`) applies consistent spacing between adjacent sibling elements: ```css .flow > * + * { margin-block-start: var(--rhythm, 1.5rem); } /* Allow overrides for specific elements */ .flow > h2 + * { margin-block-start: calc(var(--rhythm) * 0.5); } .flow > * + h2 { margin-block-start: calc(var(--rhythm) * 2); } ``` ```html Section Title First paragraph gets half-rhythm spacing from heading. Subsequent paragraphs get standard rhythm spacing. Consistent spacing throughout the article. Next Section More content with predictable spacing. ``` ### Vertical Rhythm with Grid ```css .content-grid { display: grid; row-gap: var(--rhythm); } .content-grid > h2 { margin-block-start: var(--rhythm); /* Extra space before headings */ } ``` ## Common AI Mistakes - Using arbitrary margin/padding values with no mathematical relationship (`margin: 10px`, `padding: 15px`, `gap: 22px`) - Mixing spacing units inconsistently — `px` in one place, `rem` in another, `em` in a third - Setting `margin-top` AND `margin-bottom` on adjacent elements, causing doubled spacing due to margin collapsing (or lack thereof in flex/grid contexts) - Using different spacing between identical element types — one paragraph has `margin-bottom: 16px`, the next has `margin-bottom: 20px` - Ignoring that headings need extra space above and less space below to visually associate with their following content - Not establishing a spacing scale, instead picking values ad hoc for each element - Forgetting that flex and grid containers do not collapse margins, so spacing strategies need adjustment in these contexts ## When to Use Vertical rhythm should be applied to any content-heavy layout: - Blog posts and article pages - Documentation sites - Landing pages with mixed content sections - Email templates - Any layout where multiple text elements stack vertically The strictness of the rhythm can vary: - **Strict rhythm:** Every element aligns to the baseline grid. Best for editorial/publication design - **Relaxed rhythm:** Spacing uses multiples of the base unit but does not enforce baseline alignment. Best for web applications where components have varied internal spacing For dense UI layouts (dashboards, data tables, toolbars), vertical rhythm matters less than efficient use of space. Apply rhythm principles mainly to reading-focused content areas. ## References - [Why is Vertical Rhythm an Important Typography Practice? — Zell Liew](https://zellwk.com/blog/why-vertical-rhythms/) - [Mastering CSS: Vertical Rhythm — DEV Community](https://dev.to/adrianbdesigns/mastering-css-vertical-rhythm-om9) - [Baseline Grids in CSS — edg.design](https://edgdesign.co/blog/baseline-grids-in-css) - [Creating a Vertical Rhythm with CSS Grid — Aleksandr Hovhannisyan](https://www.aleksandrhovhannisyan.com/blog/vertical-rhythm-with-css-grid/) - [8-Point Grid: Vertical Rhythm — Built to Adapt](https://medium.com/built-to-adapt/8-point-grid-vertical-rhythm-90d05ad95032) --- # prefers-reduced-motion > Source: https://takazudomodular.com/pj/zcss/docs/interactive/forms-and-accessibility/prefers-reduced-motion ## The Problem Animations and transitions can cause discomfort, dizziness, or nausea for users with vestibular disorders, motion sensitivities, or certain cognitive conditions. The `prefers-reduced-motion` media query lets users signal their preference through their operating system settings. AI agents almost never include motion preference handling in generated code, and when they do, they tend to remove all motion entirely — which can actually harm usability by removing helpful state-change indicators. ## The Solution Respect the `prefers-reduced-motion: reduce` preference by **reducing** rather than **removing** motion. Replace large, fast, or parallax-style animations with subtle fades or instant state changes. Keep functional indicators (like focus rings and loading states) intact. ### Two Approaches 1. **Remove-motion approach**: Write animations normally, then disable them in a `prefers-reduced-motion: reduce` block. 2. **No-motion-first approach**: Write static styles by default, then add animations in a `prefers-reduced-motion: no-preference` block. This is safer because users without a preference set still get reduced motion. Full Motion Loading spinner Bouncing element Reduced Motion Pulsing indicator Gentle fade only `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .column { display: flex; flex-direction: column; gap: 0.75rem; } .col-title { font-size: 0.875rem; font-weight: 700; color: #1e293b; margin: 0; text-align: center; } .box { padding: 1rem; border-radius: 0.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #475569; } .box-full { background: #eff6ff; border: 1px solid #bfdbfe; } .box-reduced { background: #f0fdf4; border: 1px solid #bbf7d0; } .spinner { width: 1.5rem; height: 1.5rem; border: 3px solid #e2e8f0; border-radius: 50%; } .spinner-full { border-top-color: #3b82f6; animation: spin 0.8s linear infinite; } .spinner-reduced { border-top-color: #22c55e; animation: pulse 1.5s ease-in-out infinite; } @keyframes spin { to { transform: rotate(360deg); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } .bounce-box { animation: bounce-anim 1s ease-in-out infinite; } @keyframes bounce-anim { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } .fade-box { animation: fade-anim 2s ease-in-out infinite; } @keyframes fade-anim { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } `} /> ## Code Examples ### Global Reduced-Motion Reset A defensive reset that reduces all animations for users who prefer reduced motion: ```css @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } ``` This is a blunt tool — use it as a baseline, then refine specific components as needed. ### Replacing Motion with Fades (Better Approach) Instead of removing all animation, replace large motion with subtle opacity changes: ```css /* Default: slide-in animation */ .modal { animation: modal-enter 0.3s ease-out; } @keyframes modal-enter { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } /* Reduced motion: fade only, no spatial movement */ @media (prefers-reduced-motion: reduce) { .modal { animation: modal-fade-in 0.2s ease-out; } @keyframes modal-fade-in { from { opacity: 0; } to { opacity: 1; } } } ``` ### No-Motion-First Approach Start with no animation and add it only when the user has no motion preference: ```css /* Base: static, no animation */ .card { opacity: 1; transform: none; } /* Only animate for users without motion preference */ @media (prefers-reduced-motion: no-preference) { .card { animation: card-reveal 0.4s ease-out both; } @keyframes card-reveal { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } } ``` ### Transitioning Safely ```css .button { background-color: var(--color-primary); } /* Hover transition: only for no-preference users */ @media (prefers-reduced-motion: no-preference) { .button { transition: background-color 0.15s ease, transform 0.15s ease; } } @media (hover: hover) { .button:hover { background-color: var(--color-primary-dark); } } /* Reduced motion users still see the color change, just instantly */ ``` ### Loading Spinner Alternative ```css .spinner { width: 2rem; height: 2rem; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Reduced motion: pulsing opacity instead of spinning */ @media (prefers-reduced-motion: reduce) { .spinner { animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } } ``` ### Scroll Behavior ```css html { scroll-behavior: smooth; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } ``` ### Parallax and Scroll-Driven Animations ```css .hero__background { animation: parallax linear; animation-timeline: scroll(); } @keyframes parallax { from { transform: translateY(-15%); } to { transform: translateY(15%); } } /* Disable parallax entirely for reduced motion */ @media (prefers-reduced-motion: reduce) { .hero__background { animation: none; transform: none; } } ``` ### JavaScript Detection For animations controlled by JavaScript: ```html const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ); function handleMotionPreference() { if (prefersReducedMotion.matches) { // Disable JS-driven animations document.documentElement.dataset.reducedMotion = "true"; } else { delete document.documentElement.dataset.reducedMotion; } } prefersReducedMotion.addEventListener("change", handleMotionPreference); handleMotionPreference(); ``` ```css /* Use the data attribute for JS-controlled animations */ [data-reduced-motion="true"] .js-animated { animation: none !important; transition: none !important; } ``` ### What to Keep vs. What to Reduce ```css /* KEEP: Focus indicators (functional, not decorative) */ .button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; /* No transition needed — instant is fine */ } /* KEEP: Color changes (not spatial motion) */ @media (prefers-reduced-motion: reduce) { .button:hover { /* Color change is fine, remove transform */ background-color: var(--color-primary-dark); transform: none; } } /* REDUCE: Large spatial movement */ @media (prefers-reduced-motion: reduce) { .slide-in-panel { /* Replace slide with fade */ animation: fade-in 0.15s ease; } } /* REMOVE: Parallax, background movement, continuous animations */ @media (prefers-reduced-motion: reduce) { .background-animation, .parallax-layer, .floating-element { animation: none; } } ``` ## Common AI Mistakes - **Not including `prefers-reduced-motion` at all**: The most frequent mistake. AI generates animations without any motion preference handling. - **Removing all animation with a blanket rule**: Killing every animation and transition removes helpful state indicators. Reduce motion, do not eliminate it. - **Forgetting `scroll-behavior: auto`**: Setting `scroll-behavior: smooth` without an opt-out for reduced-motion users. - **Not replacing removed animations**: Removing a slide-in animation without providing a fade alternative, leaving users with no state-change indicator. - **Only handling CSS animations**: Forgetting that JavaScript-driven animations (GSAP, Framer Motion, etc.) also need to respect the preference. - **Testing only the default state**: Not verifying what the experience looks like with reduced motion enabled. Chrome DevTools can emulate this: Rendering panel > Emulate CSS media feature > prefers-reduced-motion: reduce. ## When to Use - **Every project with animations**: If you add any animation or transition, add `prefers-reduced-motion` handling. - **Parallax and scroll effects**: These should always be disabled for reduced-motion users. - **Auto-playing animations**: Continuous decorative animations (floating elements, background effects) should stop. - **Page transitions**: Full-page route transitions should be reduced to simple fades or removed. - **Keep functional motion**: Loading indicators, focus rings, and state-change indicators should be preserved (possibly simplified, but not removed). ## References - [prefers-reduced-motion — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) - [prefers-reduced-motion — CSS-Tricks](https://css-tricks.com/almanac/rules/m/media/prefers-reduced-motion/) - [Taking a No-Motion-First Approach — Tatiana Mac](https://www.tatianamac.com/posts/prefers-reduced-motion) - [C39: Using prefers-reduced-motion to Prevent Motion — W3C](https://www.w3.org/WAI/WCAG21/Techniques/css/C39) - [Design Accessible Animation and Movement — Pope Tech](https://blog.pope.tech/2025/12/08/design-accessible-animation-and-movement/) --- # Overscroll Behavior > Source: https://takazudomodular.com/pj/zcss/docs/interactive/scroll/overscroll-behavior ## The Problem When a user scrolls to the end of a nested scrollable area — a modal, sidebar, dropdown, or chat panel — the browser "chains" the scroll event to the nearest scrollable ancestor. The background page suddenly starts scrolling beneath the overlay. This is called **scroll chaining**, and it is one of the most common UX bugs in web applications. Before `overscroll-behavior`, preventing this required JavaScript scroll-locking hacks: listening for wheel events, calculating scroll positions, and calling `preventDefault()` at the right moment. These solutions were fragile, caused jank, and often broke touch device scrolling entirely. On mobile, overscroll effects like pull-to-refresh could also trigger unexpectedly inside custom scroll areas. ## The Solution The `overscroll-behavior` property gives you single-line CSS control over what happens when a scroll container reaches its boundary. - **`auto`** (default): Normal behavior — scroll chains to the parent and native overscroll effects (bounce, pull-to-refresh) are active. - **`contain`**: Prevents scroll chaining to the parent, but native overscroll effects (like the rubber-band bounce on iOS/macOS) still apply within the element itself. - **`none`**: Prevents both scroll chaining and all native overscroll effects. The scroll simply stops. You can also control each axis independently with `overscroll-behavior-x` and `overscroll-behavior-y`. ### Core Principles #### auto (Default) The default value. Scrolling chains to the parent when the element reaches its scroll boundary. This is usually fine for the main page content but causes problems in overlays, modals, and sidebars. #### contain The most commonly needed value. It prevents scroll chaining so that scrolling stays trapped inside the element. Use this on any independently scrollable region — modals, sidebars, chat panels, dropdown menus — where you do not want the background to scroll. #### none Goes further than `contain` by also suppressing native overscroll effects like the rubber-band bounce or pull-to-refresh. Use this when you want scrolling to stop completely at the boundary with no visual feedback. Useful for embedded app-like interfaces. Background Page This outer area is scrollable. Try scrolling inside the inner panel below until you reach its end — then keep scrolling. The outer page will start scrolling too. This is scroll chaining. Inner Scrollable Panel Item 1 — Scroll down inside this panel Item 2 — Keep scrolling past the end Item 3 — The outer page will start moving Item 4 — This is the scroll chaining problem Item 5 — No overscroll-behavior is set here Item 6 — So the browser chains the scroll Item 7 — To the nearest scrollable ancestor Item 8 — Which is the outer container Item 9 — Causing unexpected page movement Item 10 — This is a very common UX bug Item 11 — That frustrates users Item 12 — Especially on modal overlays More background content below the panel. If scroll chaining is happening, you will see this area scroll into view when the inner panel reaches its end. Even more content here to demonstrate the outer scroll area is tall enough to scroll. And even more content to make it clearly scrollable. Bottom of the outer scrollable area. `} css={` .outer-scroll { height: 100%; overflow-y: auto; padding: 1rem; background: hsl(210 20% 96%); } .page-header { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 25%); margin-bottom: 0.5rem; } .page-text { font-size: 0.8125rem; color: hsl(210 15% 40%); line-height: 1.5; margin: 0.5rem 0; } .inner-panel { background: hsl(0 0% 100%); border: 2px solid hsl(0 70% 60%); border-radius: 0.5rem; padding: 0.75rem; height: 150px; overflow-y: auto; margin: 0.75rem 0; /* No overscroll-behavior — scroll chains to parent */ } .panel-header { font-size: 0.875rem; font-weight: 700; color: hsl(0 70% 45%); margin-bottom: 0.5rem; } .inner-panel p { font-size: 0.75rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> Background Page This outer area is scrollable, just like before. But now the inner panel has overscroll-behavior: contain. Try scrolling inside the panel past its end — the outer page will NOT scroll. Inner Panel (contained) Item 1 — Scroll down inside this panel Item 2 — Keep scrolling past the end Item 3 — The outer page stays still Item 4 — overscroll-behavior: contain fixes it Item 5 — Scroll chaining is prevented Item 6 — The scroll stays trapped here Item 7 — No JavaScript required Item 8 — Just one line of CSS Item 9 — Works on all modern browsers Item 10 — Touch devices included Item 11 — Much better UX Item 12 — The background page is safe More background content. This time it will NOT scroll when you reach the end of the inner panel. You can still scroll the outer area by scrolling outside the inner panel. More outer content to demonstrate the page is scrollable on its own. And more content here. Bottom of the outer scrollable area. `} css={` .outer-scroll { height: 100%; overflow-y: auto; padding: 1rem; background: hsl(210 20% 96%); } .page-header { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 25%); margin-bottom: 0.5rem; } .page-text { font-size: 0.8125rem; color: hsl(210 15% 40%); line-height: 1.5; margin: 0.5rem 0; } .inner-panel { background: hsl(0 0% 100%); border: 2px solid hsl(140 60% 40%); border-radius: 0.5rem; padding: 0.75rem; height: 150px; overflow-y: auto; margin: 0.75rem 0; overscroll-behavior: contain; } .panel-header { font-size: 0.875rem; font-weight: 700; color: hsl(140 60% 30%); margin-bottom: 0.5rem; } .inner-panel p { font-size: 0.75rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> overscroll-behavior: contain Line 1 — Scroll to the bottom Line 2 — Native bounce effects still show Line 3 — on supported platforms (macOS/iOS) Line 4 — but scroll chaining is prevented Line 5 — The rubber-band effect still works Line 6 — within this element Line 7 — Only chaining to the parent Line 8 — is blocked by contain Line 9 — This is the most common value Line 10 — for modals and sidebars Line 11 — Use contain by default Line 12 — unless you need none overscroll-behavior: none Line 1 — Scroll to the bottom Line 2 — No bounce effects at all Line 3 — No pull-to-refresh Line 4 — No rubber-band overscroll Line 5 — Scroll just stops completely Line 6 — at the boundary Line 7 — No chaining either Line 8 — Like contain but stricter Line 9 — Good for app-like interfaces Line 10 — or embedded panels Line 11 — Use none when you want Line 12 — no overscroll feedback `} css={` .comparison { display: flex; gap: 0.75rem; height: 100%; padding: 0.75rem; background: hsl(210 20% 96%); } .column { flex: 1; display: flex; flex-direction: column; min-width: 0; } .column-label { font-size: 0.6875rem; font-weight: 700; padding: 0.375rem 0.5rem; border-radius: 0.375rem 0.375rem 0 0; text-align: center; } .contain-label { background: hsl(200 70% 50%); color: hsl(0 0% 100%); } .none-label { background: hsl(270 60% 50%); color: hsl(0 0% 100%); } .scroll-box { flex: 1; overflow-y: auto; background: hsl(0 0% 100%); padding: 0.5rem; border-radius: 0 0 0.375rem 0.375rem; } .contain-box { overscroll-behavior: contain; border: 2px solid hsl(200 70% 50%); border-top: none; } .none-box { overscroll-behavior: none; border: 2px solid hsl(270 60% 50%); border-top: none; } .scroll-box p { font-size: 0.6875rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> T Team Chat 3 members online Alice Hey team, has anyone looked at the new design specs? 10:02 AM Yes, I reviewed them this morning. The layout changes look good. 10:05 AM Bob I have a few concerns about the navigation redesign. Can we discuss? 10:08 AM Sure, let's set up a quick call after lunch. 10:10 AM Alice Sounds good. I'll send a calendar invite. Also, the client wants to see a progress update by Friday. 10:12 AM Bob That's tight. We should prioritize the landing page and dashboard components first. 10:14 AM Agreed. I'll focus on the dashboard today and we can review tomorrow morning. 10:15 AM Alice Perfect. I'll handle the landing page. Bob, can you update the shared components library? 10:17 AM Bob On it. I'll push the updates by end of day. 10:18 AM Great teamwork everyone. Let's keep this momentum going! 10:20 AM Type a message... Send `} css={` .chat-app { display: flex; flex-direction: column; height: 100%; background: hsl(210 20% 97%); font-family: system-ui, sans-serif; } .chat-header { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; background: hsl(220 60% 50%); color: hsl(0 0% 100%); flex-shrink: 0; } .chat-avatar { width: 2rem; height: 2rem; border-radius: 50%; background: hsl(220 60% 70%); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; } .chat-info { display: flex; flex-direction: column; } .chat-name { font-weight: 700; font-size: 0.875rem; } .chat-status { font-size: 0.6875rem; opacity: 0.8; } .chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; overscroll-behavior-y: contain; } .message { display: flex; flex-direction: column; max-width: 80%; } .message.sent { align-self: flex-end; } .message.received { align-self: flex-start; } .message-author { font-size: 0.625rem; font-weight: 600; color: hsl(220 50% 45%); margin-bottom: 0.125rem; padding-left: 0.5rem; } .message-bubble { padding: 0.5rem 0.75rem; border-radius: 1rem; font-size: 0.75rem; line-height: 1.4; } .message.sent .message-bubble { background: hsl(220 60% 50%); color: hsl(0 0% 100%); border-bottom-right-radius: 0.25rem; } .message.received .message-bubble { background: hsl(0 0% 100%); color: hsl(210 15% 25%); border-bottom-left-radius: 0.25rem; box-shadow: 0 1px 2px hsl(210 20% 85%); } .message-time { font-size: 0.5625rem; color: hsl(210 10% 60%); margin-top: 0.125rem; padding: 0 0.5rem; } .message.sent .message-time { align-self: flex-end; } .chat-input { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: hsl(0 0% 100%); border-top: 1px solid hsl(210 20% 90%); flex-shrink: 0; } .input-field { flex: 1; padding: 0.5rem 0.75rem; border-radius: 1.25rem; background: hsl(210 20% 96%); font-size: 0.75rem; color: hsl(210 10% 60%); } .send-btn { padding: 0.375rem 0.75rem; background: hsl(220 60% 50%); color: hsl(0 0% 100%); border-radius: 1.25rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; } `} /> Navigation Dashboard Analytics Customers Products Orders Inventory Marketing Campaigns Reports Revenue Expenses Team Members Roles Permissions Settings Preferences Integrations API Keys Webhooks Notifications Billing Support Documentation Dashboard This is the main content area. Scroll inside the sidebar navigation on the left — even when you reach the end of the nav list, the main content will not scroll. The sidebar uses overscroll-behavior: contain to prevent scroll chaining to this main area. This pattern is essential for any application with a fixed sidebar that has enough nav items to be scrollable. Without overscroll-behavior, users accidentally scroll the main content when they reach the end of the navigation list. Monthly Revenue $48,250 Active Users 12,847 Conversion Rate 3.24% More dashboard content follows below. This area is scrollable on its own, but the sidebar scroll will never chain into it. Additional content to make the main area longer and clearly scrollable independently from the sidebar. `} css={` .app-layout { display: flex; height: 100%; font-family: system-ui, sans-serif; } .sidebar { width: 10rem; background: hsl(220 25% 18%); color: hsl(0 0% 100%); display: flex; flex-direction: column; flex-shrink: 0; } .sidebar-header { padding: 0.75rem; font-weight: 700; font-size: 0.8125rem; border-bottom: 1px solid hsl(220 20% 28%); flex-shrink: 0; } .nav-list { list-style: none; margin: 0; padding: 0.25rem 0; overflow-y: auto; flex: 1; overscroll-behavior: contain; } .nav-item { padding: 0.375rem 0.75rem; font-size: 0.6875rem; color: hsl(220 15% 75%); cursor: pointer; transition: background 0.15s; } .nav-item:hover { background: hsl(220 20% 25%); color: hsl(0 0% 100%); } .nav-item.active { background: hsl(220 60% 50%); color: hsl(0 0% 100%); font-weight: 600; } .main-content { flex: 1; padding: 1rem; overflow-y: auto; background: hsl(210 20% 97%); } .main-title { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 20%); margin: 0 0 0.75rem; } .main-text { font-size: 0.75rem; color: hsl(210 15% 40%); line-height: 1.6; margin: 0 0 0.75rem; } .main-text code { background: hsl(210 20% 92%); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.6875rem; } .card { background: hsl(0 0% 100%); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 0.5rem; box-shadow: 0 1px 3px hsl(210 20% 85%); } .card-title { font-size: 0.6875rem; color: hsl(210 10% 55%); margin-bottom: 0.25rem; } .card-value { font-size: 1.25rem; font-weight: 700; color: hsl(210 25% 20%); } `} /> ## Code Examples ### Preventing Scroll Chaining on a Modal ```css .modal-body { max-height: 80vh; overflow-y: auto; overscroll-behavior-y: contain; } ``` This single line prevents the background page from scrolling when the user scrolls to the end of the modal content. ### Preventing Accidental Browser Back Navigation ```css .horizontal-scroller { overflow-x: auto; overscroll-behavior-x: contain; } ``` On some browsers, a horizontal overscroll gesture triggers browser back/forward navigation. Setting `overscroll-behavior-x: contain` on horizontal scroll areas prevents this accidental navigation. ### App Shell with Contained Panels ```css .app-shell { display: grid; grid-template-columns: 250px 1fr 300px; height: 100vh; } .sidebar-nav { overflow-y: auto; overscroll-behavior: contain; } .main-content { overflow-y: auto; } .detail-panel { overflow-y: auto; overscroll-behavior: contain; } ``` In a multi-panel layout, apply `overscroll-behavior: contain` to each secondary scrollable panel so they do not chain scroll into the main content area. ### Disabling Pull-to-Refresh ```css body { overscroll-behavior-y: none; } ``` On mobile browsers, pulling down at the top of the page triggers a refresh. Setting `overscroll-behavior-y: none` on the body disables this behavior — useful for web apps that have their own refresh mechanism. ## Common AI Mistakes - **Not suggesting `overscroll-behavior` at all**: Defaulting to JavaScript scroll-locking libraries or `event.preventDefault()` hacks when a single CSS property solves the problem. - **Applying it to the wrong element**: `overscroll-behavior` must be set on the element that **has the scroll** (`overflow: auto` or `overflow: scroll`), not on a parent or wrapper. - **Always using `none` instead of `contain`**: Using `none` suppresses all overscroll feedback, which can feel unnatural. Prefer `contain` unless you specifically need to suppress bounce effects. - **Forgetting the axis-specific variants**: Using `overscroll-behavior: contain` when only one axis needs containment, like `overscroll-behavior-x: contain` for a horizontal scroller. - **Not combining with `overflow`**: `overscroll-behavior` only takes effect on scroll containers. If the element does not have `overflow: auto` or `overflow: scroll`, the property has no effect. ## When to Use - **Modals and dialogs**: Prevent background page scroll when scrolling inside a modal. - **Sidebars and navigation panels**: Keep sidebar scroll independent from the main content. - **Chat and messaging panels**: Prevent the page from scrolling when users scroll through message history. - **Dropdown menus**: Long dropdowns should not scroll the page behind them. - **Horizontal scroll areas**: Prevent accidental browser back/forward navigation with `overscroll-behavior-x: contain`. - **Mobile web apps**: Disable pull-to-refresh with `overscroll-behavior-y: none` on the body when the app handles its own refresh logic. ## References - [overscroll-behavior — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) - [overscroll-behavior — web.dev](https://developer.chrome.com/blog/overscroll-behavior) - [CSS overscroll-behavior — Ahmad Shadeed](https://ishadeed.com/article/css-overscroll-behavior) --- # Parent-State Child Styling > Source: https://takazudomodular.com/pj/zcss/docs/interactive/selectors/parent-state-child-styling ## The Problem Styling child elements based on a parent's interactive state (hover, focus, checked, etc.) is one of the most common UI patterns — cards where hovering highlights the title, nav items where focusing an input changes an icon, form groups where checking a box reveals more content. AI agents tend to reach for JavaScript event listeners and class toggling for these patterns, or apply hover styles directly to each child element individually. Tailwind CSS solved this elegantly with `group` and `group-hover:` utilities, but the underlying CSS patterns are straightforward and more powerful than many developers realize. ## The Solution CSS provides three complementary mechanisms for parent-state-driven child styling: 1. **Descendant combinators with pseudo-classes** — `.parent:hover .child` targets children when the parent is hovered 2. **`:focus-within`** — Matches when the element or any descendant has focus 3. **`:has()`** — The most powerful: style a parent (and its children) based on any child's state Together, these cover every scenario that Tailwind's `group-*` utilities handle, and more. → Hover this card The icon shifts, the title changes color, and the background transitions — all from a single parent :hover and :focus-within. → Second card Each card is independent. Hovering one does not affect the other. `} css={` .demo { display: flex; gap: 1rem; padding: 1.5rem; } .card { flex: 1; padding: 1.5rem; border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; background: white; transition: background-color 0.2s ease, border-color 0.2s ease; } .card:hover, .card:focus-within { background: hsl(220 60% 97%); border-color: hsl(220 60% 70%); } .card:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .card__icon { font-size: 1.25rem; transition: transform 0.2s ease; } .card:hover .card__icon, .card:focus-within .card__icon { transform: translateX(4px); } .card__title { margin: 0.5rem 0 0.25rem; font-size: 1rem; color: hsl(220 15% 25%); transition: color 0.2s ease; } .card:hover .card__title, .card:focus-within .card__title { color: hsl(220 70% 50%); } .card__desc { margin: 0; font-size: 0.8rem; color: hsl(220 10% 50%); line-height: 1.5; } `} /> ## Code Examples ### Basic: Parent Hover → Child Styling The simplest pattern. When the parent is hovered, children respond. ```css .card:hover .card__title { color: blue; } .card:hover .card__icon { transform: translateX(4px); } .card:hover .card__arrow { opacity: 1; } ``` This is what Tailwind's `group` / `group-hover:` compiles to. The parent is the "group" and children react to its state. ### Focus-Within: Keyboard-Accessible Group Focus `:focus-within` matches when the element itself or **any descendant** has focus. This is essential for keyboard accessibility — it gives you the same coordinated styling that `:hover` provides on the parent, but for focus events. ```css /* The search bar container highlights when its input is focused */ .search-bar:focus-within { border-color: hsl(220 70% 50%); box-shadow: 0 0 0 3px hsl(220 70% 50% / 0.15); } .search-bar:focus-within .search-bar__icon { color: hsl(220 70% 50%); } .search-bar:focus-within .search-bar__label { transform: translateY(-100%) scale(0.85); color: hsl(220 70% 50%); } ``` ⌕ Click the input or press Tab — the entire search bar highlights via :focus-within `} css={` .demo { padding: 2rem; } .hint { font-size: 0.75rem; color: hsl(220 10% 55%); font-style: italic; margin-top: 0.75rem; } .search-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1rem; border: 2px solid hsl(220 15% 80%); border-radius: 0.5rem; background: white; transition: border-color 0.2s ease, box-shadow 0.2s ease; } .search-bar:focus-within { border-color: hsl(220 70% 50%); box-shadow: 0 0 0 3px hsl(220 70% 50% / 0.15); } .search-bar__icon { font-size: 1.25rem; color: hsl(220 10% 60%); transition: color 0.2s ease; } .search-bar:focus-within .search-bar__icon { color: hsl(220 70% 50%); } .search-bar__input { border: none; outline: 2px solid transparent; font-size: 0.9rem; flex: 1; background: transparent; color: hsl(220 15% 20%); } .search-bar__input::placeholder { color: hsl(220 10% 65%); } `} /> ### :has() — The Most Powerful Group Pattern `:has()` goes beyond hover and focus. It lets you style a parent (and its children) based on **any child state**: checked checkboxes, filled inputs, selected options, or even structural conditions. ```css /* Highlight the form group when its checkbox is checked */ .option-group:has(input:checked) { background: hsl(220 60% 97%); border-color: hsl(220 70% 50%); } .option-group:has(input:checked) .option-group__label { color: hsl(220 70% 40%); font-weight: 600; } /* Reveal extra content when checked */ .option-group:has(input:checked) .option-group__details { display: block; } ``` Free Basic features, 1 project Pro Unlimited projects, priority support Team Everything in Pro, plus team management `} css={` .demo { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.5rem; } .option-card { display: block; border: 2px solid hsl(220 15% 85%); border-radius: 0.5rem; cursor: pointer; transition: border-color 0.2s ease, background-color 0.2s ease; } .option-card:hover { border-color: hsl(220 30% 70%); } .option-card:has(input:checked) { border-color: hsl(220 70% 50%); background: hsl(220 60% 97%); } .option-card:has(input:focus-visible) { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .option-card__input { position: absolute; opacity: 0; pointer-events: none; } .option-card__content { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; } .option-card__indicator { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 2px solid hsl(220 15% 70%); flex-shrink: 0; position: relative; transition: border-color 0.2s ease; } .option-card:has(input:checked) .option-card__indicator { border-color: hsl(220 70% 50%); } .option-card:has(input:checked) .option-card__indicator::after { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: hsl(220 70% 50%); } .option-card__title { font-weight: 600; font-size: 0.95rem; color: hsl(220 15% 25%); transition: color 0.2s ease; } .option-card:has(input:checked) .option-card__title { color: hsl(220 70% 40%); } .option-card__desc { font-size: 0.8rem; color: hsl(220 10% 55%); margin-top: 0.125rem; } `} /> ### Combining Hover and Focus-Within For robust interactive components, layer both `:hover` and `:focus-within` so the component works for mouse and keyboard users alike. ```css .nav-item:hover .nav-item__tooltip, .nav-item:focus-within .nav-item__tooltip { opacity: 1; transform: translateY(0); pointer-events: auto; } ``` Home Go to homepage Settings Manage your preferences Profile View your profile Hover or Tab to each button — the tooltip appears via :hover + :focus-within `} css={` .demo { padding: 2rem; } .hint { font-size: 0.75rem; color: hsl(220 10% 55%); font-style: italic; margin-top: 1.5rem; } .nav { display: flex; gap: 0.5rem; } .nav-item { position: relative; } .nav-item__btn { padding: 0.5rem 1rem; border: 1px solid hsl(220 15% 80%); border-radius: 0.375rem; background: white; font-size: 0.85rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease; } .nav-item:hover .nav-item__btn, .nav-item:focus-within .nav-item__btn { background: hsl(220 60% 97%); border-color: hsl(220 60% 70%); } .nav-item__btn:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .nav-item__tooltip { position: absolute; top: calc(100% + 0.5rem); left: 50%; transform: translateX(-50%) translateY(4px); padding: 0.375rem 0.75rem; background: hsl(220 20% 20%); color: white; font-size: 0.75rem; border-radius: 0.25rem; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.15s ease, transform 0.15s ease; } .nav-item:hover .nav-item__tooltip, .nav-item:focus-within .nav-item__tooltip { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } `} /> ### Toggle Visibility with :has(:checked) A classic pattern: show/hide content sections based on a checkbox or radio state, with no JavaScript. What is parent-state styling? › Parent-state styling means applying CSS to child elements based on the parent's interactive state — hover, focus, checked, etc. This is what Tailwind's group/group-hover utilities compile to. Do I need JavaScript for this? › No. Descendant combinators with :hover, :focus-within, and :has(:checked) cover the vast majority of interactive parent-child patterns without any JavaScript. What about browser support? › :hover and :focus-within have universal support. :has() is supported in all major browsers since December 2023 (Chrome 105+, Safari 15.4+, Firefox 121+). `} css={` .accordion { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; } .accordion__item { border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; overflow: hidden; transition: border-color 0.2s ease; } .accordion__item:has(.accordion__trigger:checked) { border-color: hsl(220 60% 70%); } .accordion__item:has(.accordion__trigger:focus-visible) { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .accordion__trigger { position: absolute; opacity: 0; pointer-events: none; } .accordion__header { display: flex; align-items: center; justify-content: space-between; padding: 0.875rem 1rem; cursor: pointer; background: white; transition: background-color 0.15s ease; } .accordion__header:hover { background: hsl(220 30% 97%); } .accordion__title { font-size: 0.9rem; font-weight: 600; color: hsl(220 15% 25%); } .accordion__chevron { font-size: 1.25rem; color: hsl(220 10% 55%); transition: transform 0.2s ease; } .accordion__item:has(.accordion__trigger:checked) .accordion__chevron { transform: rotate(90deg); } .accordion__body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease; } .accordion__item:has(.accordion__trigger:checked) .accordion__body { grid-template-rows: 1fr; } .accordion__body-inner { overflow: hidden; } .accordion__body-inner > p { margin: 0; padding: 0 1rem 1rem; font-size: 0.85rem; color: hsl(220 10% 40%); line-height: 1.6; } `} /> ### Nested Groups: Multiple Ancestor Levels When you need different ancestor levels to drive different child styles, use distinct class names for each level. ```css /* Outer group: the card */ .card:hover .card__badge { background: hsl(220 70% 50%); } /* Inner group: the card footer */ .card__footer:hover .card__footer-link { text-decoration: underline; } ``` This is the CSS equivalent of Tailwind's named groups (`group/card`, `group/footer`) — each ancestor's state independently controls its own descendants. New Nested Group Demo Hover the card to highlight the badge. Then hover the footer area to see the link underline. Read more → `} css={` .demo { padding: 1.5rem; max-width: 320px; } .card { border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; padding: 1.25rem; background: white; transition: border-color 0.2s ease; position: relative; } .card:hover { border-color: hsl(220 50% 70%); } .card__badge { display: inline-block; padding: 0.2rem 0.5rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border-radius: 0.25rem; background: hsl(220 15% 88%); color: hsl(220 15% 40%); transition: background-color 0.2s ease, color 0.2s ease; } /* Outer group: card hover changes badge */ .card:hover .card__badge { background: hsl(220 70% 50%); color: white; } .card__title { font-size: 1rem; margin: 0.75rem 0 0.25rem; color: hsl(220 15% 20%); } .card__desc { font-size: 0.8rem; color: hsl(220 10% 50%); line-height: 1.5; margin: 0; } .card__footer { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid hsl(220 15% 90%); } .card__footer-link { font-size: 0.85rem; color: hsl(220 70% 50%); transition: text-decoration-color 0.15s ease; text-decoration: underline transparent; } .card__footer-link:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; border-radius: 2px; } /* Inner group: footer hover changes link */ .card__footer:hover .card__footer-link, .card__footer:focus-within .card__footer-link { text-decoration: underline hsl(220 70% 50%); } `} /> ## Tailwind group → CSS Mapping | Tailwind Utility | CSS Equivalent | |---|---| | `group` + `group-hover:text-blue` | `.parent:hover .child { color: blue; }` | | `group` + `group-focus:opacity-100` | `.parent:focus .child { opacity: 1; }` | | `group` + `group-focus-within:ring-2` | `.parent:focus-within .child { ... }` | | `group` + `group-active:scale-95` | `.parent:active .child { transform: scale(0.95); }` | | `group/name` (named groups) | Use distinct class names per ancestor level | | `group-has-[:checked]:bg-blue` | `.parent:has(:checked) { background: blue; }` | ## Common AI Mistakes - **Using JavaScript to toggle classes for hover effects** — Use `.parent:hover .child` instead. No event listeners needed. - **Duplicating hover styles on every child element** — Apply `:hover` to the parent once, then style all children from that single rule. - **Forgetting keyboard accessibility** — Always pair `:hover` with `:focus-within` for interactive containers. Hover-only patterns exclude keyboard users. - **Over-nesting with :has()** — When a simple `.parent:hover .child` suffices, don't reach for `:has()`. Use `:has()` when you need to react to a child's internal state (checked, valid, empty). ## When to Use - **Card hover effects**: Coordinated title, icon, and background changes - **Form groups**: Highlight the entire group when an input is focused or invalid - **Navigation menus**: Show tooltips or dropdowns on hover/focus - **Option selectors**: Style selected options based on radio/checkbox state - **Accordion/disclosure**: Toggle visibility with `:has(:checked)` - **Any pattern where Tailwind uses `group`** — the CSS is always a descendant combinator + pseudo-class --- # View Transitions > Source: https://takazudomodular.com/pj/zcss/docs/interactive/states-and-transitions/view-transitions ## The Problem Creating smooth animated transitions between page states or during navigation has traditionally required complex JavaScript animation libraries, manual DOM manipulation, or framework-specific solutions like React Transition Group. Page navigations (both SPA and MPA) result in abrupt content swaps with no visual continuity. AI agents default to JavaScript-heavy animation approaches and almost never suggest the View Transitions API. ## The Solution The View Transitions API provides a native mechanism for creating animated transitions between DOM states. The browser captures a snapshot of the old state, applies the DOM update, then animates between old and new snapshots using CSS. For same-document (SPA) transitions, use `document.startViewTransition()`. For cross-document (MPA) transitions, use the `@view-transition` CSS at-rule to opt both pages in. ## Code Examples ### Same-Document View Transition (SPA) ```css /* Default crossfade animation — works with no extra CSS */ ::view-transition-old(root) { animation: fade-out 0.3s ease-out; } ::view-transition-new(root) { animation: fade-in 0.3s ease-in; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } ``` ```html function updateContent(newHTML) { if (!document.startViewTransition) { // Fallback: just update directly document.getElementById('content').innerHTML = newHTML; return; } document.startViewTransition(() => { document.getElementById('content').innerHTML = newHTML; }); } ``` ### Named View Transitions for Element-Level Animation Give specific elements their own transition by assigning a `view-transition-name`. ```css .product-image { view-transition-name: product-image; } .product-title { view-transition-name: product-title; } /* Customize the animation for the product image */ ::view-transition-old(product-image) { animation: scale-down 0.4s ease-in; } ::view-transition-new(product-image) { animation: scale-up 0.4s ease-out; } @keyframes scale-down { from { transform: scale(1); } to { transform: scale(0.8); opacity: 0; } } @keyframes scale-up { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); } } ``` ### Cross-Document View Transitions (MPA) Opt both pages into the transition with the `@view-transition` at-rule. ```css /* Include this in BOTH the source and destination pages */ @view-transition { navigation: auto; } /* Shared element transitions across pages */ .hero-image { view-transition-name: hero; } /* Customize the cross-document transition */ ::view-transition-old(hero) { animation-duration: 0.4s; } ::view-transition-new(hero) { animation-duration: 0.4s; } ``` ### Slide Transition Between Pages ```css @view-transition { navigation: auto; } @keyframes slide-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes slide-to-left { from { transform: translateX(0); } to { transform: translateX(-100%); } } ::view-transition-old(root) { animation: slide-to-left 0.4s ease-in-out; } ::view-transition-new(root) { animation: slide-from-right 0.4s ease-in-out; } ``` ### Using `view-transition-class` for Grouped Animations Apply the same animation to multiple named transitions without repeating CSS. ```css .card-1 { view-transition-name: card-1; } .card-2 { view-transition-name: card-2; } .card-3 { view-transition-name: card-3; } /* Apply the same animation class to all cards */ .card-1, .card-2, .card-3 { view-transition-class: card; } /* One rule animates all card transitions */ ::view-transition-group(*.card) { animation-duration: 0.35s; animation-timing-function: ease-in-out; } ``` ### Conditional Transitions with View Transition Types ```html function navigateForward(updateFn) { const transition = document.startViewTransition({ update: updateFn, types: ['slide-forward'], }); } function navigateBack(updateFn) { const transition = document.startViewTransition({ update: updateFn, types: ['slide-back'], }); } ``` ```css /* Forward navigation */ :active-view-transition-type(slide-forward) { &::view-transition-old(root) { animation: slide-to-left 0.3s ease-in-out; } &::view-transition-new(root) { animation: slide-from-right 0.3s ease-in-out; } } /* Back navigation */ :active-view-transition-type(slide-back) { &::view-transition-old(root) { animation: slide-to-right 0.3s ease-in-out; } &::view-transition-new(root) { animation: slide-from-left 0.3s ease-in-out; } } ``` ### Respecting User Preferences ```css @media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.01ms; } } ``` ## Browser Support ### Same-Document View Transitions - Chrome 111+ - Edge 111+ - Safari 18+ - Firefox 144+ (shipping October 2025 — part of Interop 2025) ### Cross-Document View Transitions - Chrome 126+ - Edge 126+ - Safari 18.2+ - Firefox: not yet supported Same-document transitions have broad support. Cross-document transitions are supported in Chromium and Safari but not yet in Firefox. Always provide a fallback by checking for `document.startViewTransition` before calling it. ## Common AI Mistakes - Using JavaScript animation libraries (GSAP, Framer Motion) for transitions that the View Transitions API handles natively - Not checking for `document.startViewTransition` support before calling it - Forgetting to add `@view-transition { navigation: auto; }` to **both** pages for cross-document transitions - Not assigning `view-transition-name` to shared elements that should animate independently from the page - Making `view-transition-name` values non-unique on the same page (each name must be unique at transition time) - Not respecting `prefers-reduced-motion` by disabling or shortening animations for users who prefer reduced motion - Over-animating: using view transitions for every small UI update instead of meaningful state changes ## When to Use - Page-to-page navigation transitions (both SPA and MPA) - Content updates within a page (tab switches, list filtering, detail views) - Shared element transitions between list and detail views (e.g., product thumbnails) - Any state change where visual continuity helps the user understand what changed - Replacing complex JavaScript animation setups with native browser capabilities ## Live Previews Page A Original Content → Page B New Content How it works: The browser captures a snapshot of the old state, updates the DOM, then crossfades between old and new using ::view-transition-old and ::view-transition-new pseudo-elements. View transitions require JavaScript (document.startViewTransition()) to trigger — this static demo shows the visual pattern. `} css={` .demo { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; gap: 1.5rem; } .page { width: 140px; border-radius: 12px; overflow: hidden; border: 2px solid #e2e8f0; } .page-old { opacity: 0.5; border-color: #fca5a5; } .page-new { border-color: #86efac; box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15); } .header { padding: 0.5rem 0.75rem; font-weight: 700; font-size: 0.8rem; } .page-old .header { background: #fef2f2; color: #dc2626; } .page-new .header { background: #f0fdf4; color: #16a34a; } .content { padding: 1.5rem 0.75rem; text-align: center; font-size: 0.8rem; color: #64748b; background: white; } .arrow { font-size: 2rem; color: #94a3b8; font-weight: 300; } .code-note { margin-top: 1rem; padding: 1rem; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; font-family: system-ui, sans-serif; font-size: 0.8rem; color: #64748b; line-height: 1.5; } .code-note p { margin: 0 0 0.5rem; } .code-note p:last-child { margin: 0; } code { background: #e2e8f0; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## References - [View Transition API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - [Smooth transitions with the View Transition API - Chrome for Developers](https://developer.chrome.com/docs/web-platform/view-transitions) - [What's new in view transitions (2025 update) - Chrome for Developers](https://developer.chrome.com/blog/view-transitions-in-2025) - [@view-transition - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@view-transition) - [A Practical Guide to the CSS View Transition API - Cyd Stumpel](https://cydstumpel.nl/a-practical-guide-to-the-css-view-transition-api/) --- # Subgrid > Source: https://takazudomodular.com/pj/zcss/docs/layout/flexbox-and-grid/subgrid ## The Problem CSS Grid is excellent for laying out direct children, but nested elements cannot participate in the parent grid's track sizing. This makes it impossible to align content across sibling components — for example, ensuring card titles, descriptions, and buttons all align at the same vertical position across a row of cards. AI agents almost never use `subgrid` and instead resort to fixed heights, JavaScript-based alignment, or complex workarounds that break with dynamic content. ## The Solution The `subgrid` value for `grid-template-columns` and/or `grid-template-rows` allows a nested grid to inherit its parent's track definitions. The child grid participates in the parent's track sizing, so content across sibling grid items aligns naturally without fixed dimensions or duplication of track definitions. ## Code Examples ### Card Layout with Aligned Content The most common subgrid use case: ensuring headings, content, and footers align across cards. ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } .card { display: grid; /* Inherit parent's row tracks for this card's internal layout */ grid-row: span 3; grid-template-rows: subgrid; gap: 0.75rem; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.5rem; } .card h2 { /* Row 1: title — aligns across all cards */ align-self: start; } .card p { /* Row 2: description — aligns across all cards */ align-self: start; } .card .action { /* Row 3: button — pushed to bottom, aligned across all cards */ align-self: end; } ``` ```html Short Title Brief description. Read more A Much Longer Card Title That Wraps This card has a longer title, but the description and button still align with the other cards. Read more Medium Title Another card with varying content length. Read more ``` ### Subgrid for Columns Align nested form labels and inputs to the parent grid's columns. ```css .form-grid { display: grid; grid-template-columns: 120px 1fr; gap: 1rem; } .field-group { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; } .field-group label { grid-column: 1; } .field-group input { grid-column: 2; } ``` ```html Name Email Phone ``` ### Two-Dimensional Subgrid Inherit both rows and columns from the parent grid. ```css .dashboard { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto 1fr auto; gap: 1rem; } .widget { display: grid; grid-column: span 1; grid-row: span 3; grid-template-columns: subgrid; grid-template-rows: subgrid; } .widget header { /* Aligns with other widgets' headers */ font-weight: bold; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; } .widget footer { /* Aligns with other widgets' footers */ font-size: 0.875rem; color: #6b7280; } ``` ### Subgrid with Named Lines Named lines from the parent grid flow through to the subgrid. ```css .page-layout { display: grid; grid-template-columns: [full-start] 1fr [content-start] minmax(0, 65ch) [content-end] 1fr [full-end]; } .article { display: grid; grid-column: full-start / full-end; grid-template-columns: subgrid; } .article p { grid-column: content-start / content-end; } .article .wide-image { grid-column: full-start / full-end; } ``` ### Subgrid Without Duplicating Gap The subgrid inherits the parent's `gap` by default. You can override it if needed. ```css .parent { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2rem; } .child { display: grid; grid-column: span 2; grid-template-columns: subgrid; /* Subgrid inherits parent's 2rem gap */ /* Override if needed: */ gap: 0.5rem; } ``` ## Browser Support - Chrome 117+ - Edge 117+ - Safari 16+ - Firefox 71+ Global support exceeds 97%. Firefox was the first to ship subgrid in December 2019. Safari followed in September 2022, and Chrome/Edge added support in September 2023. Subgrid is safe for production use. ## Common AI Mistakes - Not using `subgrid` at all — most AI agents generate grid layouts with duplicated track definitions or fixed heights for alignment - Duplicating `grid-template-columns` values in child grids instead of using `subgrid` - Using JavaScript or fixed dimensions to align content across sibling grid items - Not spanning the child grid item across the correct number of parent tracks before applying `subgrid` - Forgetting that subgrid inherits the parent's `gap` value (it can be overridden) - Applying `subgrid` without setting `display: grid` on the child element first - Not leveraging subgrid for form layouts where labels and inputs need consistent alignment ## When to Use - Card grids where titles, content, and actions must align across cards - Form layouts where labels and inputs align to a shared column track - Dashboard widgets that share a common header/content/footer structure - Full-bleed content layouts where nested elements need to reference the parent grid's named lines - Any layout where nested elements need to participate in the parent grid's track sizing ## Live Previews Short Title Brief description of this card. Read more → A Much Longer Card Title That Wraps to Multiple Lines Despite the longer title, the description and action button still align perfectly with other cards thanks to subgrid. Read more → Medium Title Another card with varying content length to show alignment. Read more → Notice how titles, descriptions, and buttons all align across cards — subgrid shares the parent's row tracks `} css={` .card-grid { font-family: system-ui, sans-serif; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .card { display: grid; grid-row: span 3; grid-template-rows: subgrid; gap: 0.5rem; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem; background: #fff; } .card h2 { font-size: 1rem; color: #1e293b; margin: 0; align-self: start; } .card p { font-size: 0.875rem; color: #64748b; margin: 0; line-height: 1.5; align-self: start; } .action { align-self: end; display: inline-block; padding: 0.5rem 1rem; background: #3b82f6; color: white; text-decoration: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; text-align: center; transition: background 0.2s; } .action:hover { background: #2563eb; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} height={320} /> ## References - [Subgrid - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Subgrid) - [CSS subgrid - web.dev](https://web.dev/articles/css-subgrid) - [Brand New Layouts with CSS Subgrid - Josh W. Comeau](https://www.joshwcomeau.com/css/subgrid/) - [CSS Subgrid - Can I Use](https://caniuse.com/css-subgrid) --- # Stacking Context > Source: https://takazudomodular.com/pj/zcss/docs/layout/positioning/stacking-context ## The Problem Stacking context is the most misunderstood concept in CSS. AI agents routinely escalate `z-index` values to absurd numbers (99999) without understanding why elements still appear behind others. The root cause is almost always that elements live in different stacking contexts, and no amount of z-index increase can break out of a parent's stacking context. ## The Solution A stacking context is an isolated layering group. Elements within one stacking context are compared against each other for z-ordering, but they are never compared against elements in a different stacking context. Understanding what creates a stacking context, and using the `isolation` property to create them intentionally, eliminates z-index wars. ## What Creates a Stacking Context The following properties create a new stacking context on an element: ### Always creates a stacking context - The root element (``) - `position: fixed` - `position: sticky` ### Creates a stacking context when a condition is met - `position: relative` or `position: absolute` **with `z-index` other than `auto`** - `opacity` less than `1` - `transform` with any value other than `none` - `filter` with any value other than `none` - `backdrop-filter` with any value other than `none` - `perspective` with any value other than `none` - `clip-path` with any value other than `none` - `mask` / `mask-image` / `mask-border` with any value other than `none` - `mix-blend-mode` with any value other than `normal` - `isolation: isolate` - `will-change` specifying any of the above properties - `contain: layout`, `contain: paint`, or `contain: strict` ## Code Examples ### The Classic z-index Bug ```css .sidebar { position: relative; z-index: 1; } .sidebar .dropdown { position: absolute; z-index: 99999; /* Will NOT appear above .main-content */ } .main-content { position: relative; z-index: 2; } ``` In this example, `.dropdown` has `z-index: 99999` but still renders behind `.main-content`. This happens because `.sidebar` creates a stacking context with `z-index: 1`. The dropdown is trapped inside that stacking context. From the perspective of `.main-content` (z-index: 2), the entire `.sidebar` group has z-index 1. Sidebar (z-index: 1) Dropdown z-index: 99999 — trapped behind main! Main Content (z-index: 2) — covers dropdown `} css={`.container { position: relative; height: 200px; font-family: system-ui, sans-serif; font-size: 14px; } .sidebar { position: relative; z-index: 1; background: #f59e0b; color: #fff; padding: 12px; width: 55%; border-radius: 8px; } .sidebar-label { font-weight: 600; margin-bottom: 8px; } .dropdown { position: absolute; top: 100%; left: 0; z-index: 99999; background: #ef4444; color: #fff; padding: 12px 16px; border-radius: 8px; width: 280px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .main-content { position: relative; z-index: 2; background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; margin-top: -8px; }`} height={220} /> ### Fix: Remove the Parent's Stacking Context ```css .sidebar { position: relative; /* Remove z-index to avoid creating a stacking context */ } .sidebar .dropdown { position: absolute; z-index: 10; } .main-content { position: relative; /* No z-index needed */ } ``` Sidebar (no z-index) Dropdown z-index: 10 — now appears on top! Main Content (no z-index) `} css={`.container { position: relative; height: 200px; font-family: system-ui, sans-serif; font-size: 14px; } .sidebar { position: relative; background: #f59e0b; color: #fff; padding: 12px; width: 55%; border-radius: 8px; } .sidebar-label { font-weight: 600; margin-bottom: 8px; } .dropdown { position: absolute; top: 100%; left: 0; z-index: 10; background: #22c55e; color: #fff; padding: 12px 16px; border-radius: 8px; width: 280px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .main-content { position: relative; background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; margin-top: -8px; }`} height={220} /> ### The isolation Property `isolation: isolate` is the cleanest way to create a stacking context. It does exactly one thing: creates a new stacking context. No side effects, no visual changes. ```css .card { isolation: isolate; /* Creates stacking context */ } .card .background { position: absolute; inset: 0; z-index: -1; /* Stays behind card content but inside card's context */ } .card .content { position: relative; /* z-index: auto, but still above .background due to DOM order */ } ``` ```html Card text ``` isolation: isolate The purple background uses z-index: -1 but stays inside the card thanks to isolation. This text is behind the card — z-index: -1 does not leak out `} css={`.demo-area { padding: 12px; background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; font-size: 14px; } .card { isolation: isolate; position: relative; border-radius: 12px; padding: 20px; margin-bottom: 12px; overflow: hidden; } .card-bg { position: absolute; inset: 0; z-index: -1; background: linear-gradient(135deg, #8b5cf6, #6d28d9); } .card-content { position: relative; color: #fff; line-height: 1.5; } .behind-text { color: #64748b; padding: 8px; text-align: center; }`} /> ### Preventing Component Leakage Use `isolation: isolate` on component root elements to prevent z-index from leaking out. ```css /* Each component is self-contained */ .modal-overlay { isolation: isolate; position: fixed; inset: 0; z-index: 100; } .tooltip { isolation: isolate; position: absolute; } .dropdown-menu { isolation: isolate; position: absolute; } ``` ### Accidental Stacking Contexts Properties that seem unrelated to layering can silently create stacking contexts. ```css /* This creates a stacking context! */ .fading-element { opacity: 0.99; } /* This also creates a stacking context! */ .animated-element { transform: translateZ(0); /* Often added for "GPU acceleration" */ } /* This too! */ .blurred-element { filter: blur(0px); } ``` Any of these will trap child z-index values inside the element's stacking context. ## Debugging Stacking Context Issues ### Step-by-step diagnosis 1. Identify the element that is layered incorrectly. 2. Walk up the DOM tree from that element to the root. 3. For each ancestor, check if it creates a stacking context (use the list above). 4. Find the stacking context boundary where the layering breaks. 5. Either remove the unnecessary stacking context, or restructure z-index values to work within the existing contexts. ### Browser DevTools In Chrome DevTools, the Layers panel (More Tools > Layers) visualizes stacking contexts. Firefox DevTools shows stacking context information in the Layout panel. ## Common AI Mistakes - **Escalating z-index values.** When an element does not appear on top, AI agents increase z-index to large numbers. This never solves stacking context issues. The fix is to understand which stacking contexts the elements belong to. - **Not knowing that `opacity`, `transform`, and `filter` create stacking contexts.** Adding `opacity: 0.99` or `transform: translateZ(0)` for performance can break z-index behavior of child elements. - **Using z-index on non-positioned elements.** `z-index` only works on positioned elements (`relative`, `absolute`, `fixed`, `sticky`) and flex/grid items. On a statically positioned element, it has no effect. - **Not using `isolation: isolate` for component boundaries.** Every reusable component with internal z-index should use `isolation: isolate` to prevent its z-index values from leaking into the parent context. - **Adding `position: relative; z-index: 1` as a general "fix".** This creates a new stacking context, which may fix the immediate issue but traps all descendant z-index values and causes new problems elsewhere. ## When to Use ### Use `isolation: isolate` when - Building reusable components that use z-index internally - You need a stacking context without any visual side effect - You want to contain `z-index: -1` elements within their parent - You want to prevent mix-blend-mode from bleeding through ### Use `z-index` when - Controlling the order of positioned elements within the same stacking context - Layering overlays, modals, and dropdowns at the page level ### Audit stacking contexts when - A z-index value "does not work" - An element with a high z-index appears behind one with a lower z-index - Adding `opacity`, `transform`, or `filter` causes unexpected layering changes ## References - [Stacking Context - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context) - [What The Heck, z-index?? - Josh W. Comeau](https://www.joshwcomeau.com/css/stacking-contexts/) - [isolation - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/isolation) - [Z-index and Stacking Contexts - web.dev](https://web.dev/learn/css/z-index) - [CSS isolation property - freeCodeCamp](https://www.freecodecamp.org/news/the-css-isolation-property/) --- # clamp() for Fluid Sizing > Source: https://takazudomodular.com/pj/zcss/docs/layout/sizing/clamp-for-sizing ## The Problem Responsive design traditionally relies on media queries to change sizes at specific breakpoints. This creates abrupt jumps — a heading might be 2rem on mobile and suddenly 3rem on desktop. AI agents tend to generate multiple media queries for font sizes, padding, and widths, producing verbose CSS that is hard to maintain. The `clamp()` function solves this by creating smooth, fluid scaling between a minimum and maximum value with a single declaration and zero media queries. ## The Solution The `clamp()` function takes three values: ``` clamp(minimum, preferred, maximum) ``` - **minimum** — the smallest the value can be - **preferred** — a viewport-relative expression that scales fluidly - **maximum** — the largest the value can be The browser uses the preferred value, but constrains it between the minimum and maximum. If the preferred value is less than the minimum, the minimum is used. If it is greater than the maximum, the maximum is used. ## Code Examples ### Fluid Typography ```css h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } h2 { font-size: clamp(1.375rem, 0.875rem + 1.5vw, 2rem); } p { font-size: clamp(1rem, 0.875rem + 0.4vw, 1.25rem); } ``` The heading scales smoothly from 1.75rem (on narrow viewports) to 3rem (on wide viewports). The `1rem + 2.5vw` preferred value combines a fixed base with a viewport-relative portion, ensuring the text still scales when the user zooms. ### Why rem + vw, Not Just vw ```css /* Bad: pure vw ignores user zoom preferences */ h1 { font-size: clamp(1.75rem, 4vw, 3rem); } /* Good: rem + vw respects user zoom */ h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } ``` Using only `vw` as the preferred value means the text does not scale when the user changes their browser zoom level (a WCAG accessibility requirement). Combining `rem` + `vw` ensures the text responds to both viewport width and zoom settings. ### Calculating the Preferred Value To scale linearly from a minimum size at a minimum viewport to a maximum size at a maximum viewport: ``` slope = (maxSize - minSize) / (maxViewport - minViewport) intercept = minSize - slope × minViewport preferred = intercept(rem) + slope × 100(vw) ``` Example: Scale from 1.75rem at 320px to 3rem at 1280px: ``` slope = (3 - 1.75) / (80 - 20) = 1.25 / 60 = 0.02083 intercept = 1.75 - 0.02083 × 20 = 1.3334 preferred = 1.3334rem + 2.083vw ``` (Viewport widths converted to rem by dividing by 16: 320/16=20, 1280/16=80) ```css h1 { font-size: clamp(1.75rem, 1.333rem + 2.083vw, 3rem); } ``` In practice, use a clamp calculator tool rather than computing by hand. ### Fluid Spacing `clamp()` is not just for typography — use it for padding, margins, and gaps: ```css .section { padding-block: clamp(2rem, 1rem + 3vw, 5rem); } .card-grid { gap: clamp(1rem, 0.5rem + 1.5vw, 2.5rem); } .container { padding-inline: clamp(1rem, 0.5rem + 2vw, 4rem); } ``` width: clamp(200px, 60%, 500px) Resize with viewport buttons to see it adapt `} css={`.outer { padding: 16px; background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; } .fluid-box { width: clamp(200px, 60%, 500px); background: #3b82f6; color: #fff; padding: 20px; border-radius: 8px; } .label { font-size: 16px; font-weight: 600; margin-bottom: 4px; } .sublabel { font-size: 13px; opacity: 0.85; }`} /> Section with fluid padding padding-block: clamp(1rem, 0.5rem + 3vw, 4rem) padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem) Use viewport buttons to see padding change `} css={`.section { background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; } .content { background: #8b5cf6; color: #fff; border-radius: 8px; padding-block: clamp(1rem, 0.5rem + 3vw, 4rem); padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem); } .heading { font-size: 18px; font-weight: 600; margin-bottom: 8px; } .text { font-size: 14px; margin-bottom: 4px; font-family: monospace; } .hint { font-size: 13px; opacity: 0.8; margin-top: 8px; }`} /> ### Fluid Width Constraints ```css .content { max-inline-size: clamp(30rem, 90%, 60rem); } ``` The content area is at least 30rem wide, at most 60rem, and scales to 90% of the container in between. ### Full Fluid Typography System ```css :root { --text-sm: clamp(0.875rem, 0.8rem + 0.2vw, 1rem); --text-base: clamp(1rem, 0.875rem + 0.4vw, 1.25rem); --text-lg: clamp(1.25rem, 1rem + 0.75vw, 1.75rem); --text-xl: clamp(1.5rem, 1.125rem + 1.25vw, 2.25rem); --text-2xl: clamp(1.875rem, 1.25rem + 2vw, 3rem); --text-3xl: clamp(2.25rem, 1.25rem + 3vw, 4rem); --space-sm: clamp(0.5rem, 0.375rem + 0.4vw, 0.75rem); --space-md: clamp(1rem, 0.75rem + 0.75vw, 1.5rem); --space-lg: clamp(1.5rem, 1rem + 1.5vw, 3rem); --space-xl: clamp(2rem, 1rem + 3vw, 5rem); } ``` ### Replacing Media Queries ```css /* Before: multiple breakpoints */ h1 { font-size: 1.75rem; } @media (min-width: 640px) { h1 { font-size: 2rem; } } @media (min-width: 768px) { h1 { font-size: 2.5rem; } } @media (min-width: 1024px) { h1 { font-size: 3rem; } } /* After: one line, smooth scaling */ h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } ``` ## Common AI Mistakes - **Using only `vw` as the preferred value.** `font-size: clamp(1rem, 3vw, 2rem)` does not scale when users zoom their browser, violating WCAG 1.4.4. Always combine `rem` + `vw` in the preferred value. - **Generating multiple media queries for font size when `clamp()` is simpler.** A single `clamp()` declaration replaces 3-4 media queries and creates smoother scaling. - **Getting the min and max reversed.** If the minimum value is larger than the maximum, `clamp()` always returns the minimum. Triple-check that `min < max`. - **Using `px` instead of `rem` for the min and max values.** Using `rem` ensures the sizing respects user font-size preferences and zoom settings. - **Not using `clamp()` for spacing.** AI agents sometimes use `clamp()` for font sizes but still use media queries for padding, margin, and gap. All of these benefit from fluid scaling. - **Overcomplicating the preferred value.** The formula does not need to be pixel-perfect. `clamp(1rem, 0.875rem + 0.5vw, 1.25rem)` for body text is close enough — exact linear interpolation between specific viewport widths is rarely necessary. - **Nesting `clamp()` inside `calc()` unnecessarily.** `clamp()` already evaluates math expressions internally. `calc(clamp(...))` is redundant. ## When to Use ### clamp() is ideal for - Font sizes that should scale smoothly between mobile and desktop - Section padding and margins that grow with the viewport - Container widths with flexible constraints - Gap values in grid/flex layouts - Any value that currently uses 2+ media queries to scale ### Stick with media queries when - You need to change a property to a completely different value (not just a scaled version) - You are toggling layouts, not scaling sizes (e.g., switching from a single column to a sidebar) - The scaling is not linear (e.g., the size should jump at a specific breakpoint) ### Accessibility requirements - Always use `rem` + `vw` in the preferred value, never `vw` alone - Test at 200% browser zoom to ensure text remains readable - Ensure body text stays within the 45-75 character line-length range across viewports ## References - [clamp() - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/clamp) - [Modern Fluid Typography Using CSS Clamp - Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Linearly Scale font-size with CSS clamp() - CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Fluid Typography Tool](https://fluidtypography.com/) - [CSS Clamp Calculator - modern.css](https://modern-css.com/playground/css-clamp-fluid-typography/) --- # Negative Margin Expand + Padding Back > Source: https://takazudomodular.com/pj/zcss/docs/layout/specialized/negative-margin-expand ## The Problem Inside a padded container, every child element respects the container's padding and stays inset from the edges. But sometimes a section needs its background color, image, or border to visually break out of that container — extending edge-to-edge — while keeping its text content aligned with the rest of the page. AI agents typically solve this by nesting extra wrapper divs, breaking the element out of the container entirely, or using `100vw` hacks that cause horizontal scrollbars. ## The Solution Apply negative horizontal margins to pull the element beyond the container's padding, then add matching positive padding to push the content back into alignment: ```css .container { max-width: 600px; margin: 0 auto; padding: 0 24px; } .full-bleed-section { margin-left: -24px; margin-right: -24px; padding-left: 24px; padding-right: 24px; background: hsl(220 80% 96%); } ``` The negative margin and positive padding must always match. The background expands, but the text stays exactly where it was. Normal paragraph inside padded container. Notice the gap on both sides. Full-bleed section. The background extends to the container edges, but the text stays aligned with the paragraph above. Another normal paragraph below. Text alignment is consistent throughout. `} css={` body { margin: 0; background: hsl(0 0% 96%); font-family: system-ui, sans-serif; } .container { max-width: 480px; margin: 24px auto; padding: 0 32px; background: white; border: 2px solid hsl(0 0% 85%); } .normal { font-size: 14px; line-height: 1.6; color: hsl(0 0% 30%); } .full-bleed { margin-left: -32px; margin-right: -32px; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 16px; background: hsl(220 80% 96%); border-top: 1px solid hsl(220 60% 85%); border-bottom: 1px solid hsl(220 60% 85%); } .full-bleed p { font-size: 14px; line-height: 1.6; color: hsl(220 40% 30%); margin: 0; } `} height={320} /> ## Using Logical Properties For better internationalization support, use logical properties instead of physical `left`/`right`: ```css .full-bleed-section { margin-inline: -24px; padding-inline: 24px; background: hsl(220 80% 96%); } ``` ## Responsive Scaling When the container padding changes at different breakpoints, scale the break-out values to match: ```css .container { padding-inline: 16px; } .full-bleed-section { margin-inline: -16px; padding-inline: 16px; } @media (min-width: 768px) { .container { padding-inline: 32px; } .full-bleed-section { margin-inline: -32px; padding-inline: 32px; } } @media (min-width: 1024px) { .container { padding-inline: 48px; } .full-bleed-section { margin-inline: -48px; padding-inline: 48px; } } ``` Article Title Regular content sits within the container padding. Key Takeaway This highlighted section breaks out to the container edges. Try switching viewport sizes to see the responsive padding adjust. Content continues aligned with the rest of the text. `} css={` body { margin: 0; font-family: system-ui, sans-serif; background: hsl(0 0% 95%); } .container { max-width: 100%; margin: 0 auto; padding: 16px; background: white; } .container h2 { font-size: 18px; margin-bottom: 8px; color: hsl(0 0% 20%); } .container p { font-size: 14px; line-height: 1.6; color: hsl(0 0% 35%); } .highlight { margin-inline: -16px; padding-inline: 16px; padding-top: 16px; padding-bottom: 16px; background: hsl(35 90% 95%); border-left: 4px solid hsl(35 80% 55%); } .highlight h3 { font-size: 15px; margin-bottom: 4px; color: hsl(35 60% 25%); } .highlight p { color: hsl(35 30% 30%); margin: 0; } @media (min-width: 500px) { .container { padding: 32px; } .highlight { margin-inline: -32px; padding-inline: 32px; } } `} height={300} /> ## The Key Rule The negative margin must **never exceed** the parent container's padding. If it does, the element overflows the page edge and creates a horizontal scrollbar. ```css /* The container has 24px padding */ .container { padding-inline: 24px; } /* GOOD — matches the container padding */ .full-bleed { margin-inline: -24px; padding-inline: 24px; } /* BAD — exceeds the container padding, causes overflow */ .full-bleed-broken { margin-inline: -48px; padding-inline: 48px; } ``` ## Common AI Mistakes - **Using `width: 100vw` for full-bleed** — This causes horizontal scrollbars when the page has a vertical scrollbar (because `100vw` includes scrollbar width). The negative margin technique avoids this entirely. - **Forgetting to match padding with margin** — Setting `margin-inline: -32px` without `padding-inline: 32px` shifts the text content left/right, breaking alignment. - **Exceeding the container padding** — The negative margin can only pull as far as the parent has padding. Going further causes overflow. - **Using `overflow: hidden` on the parent** — This clips the expanded background. If the parent needs `overflow: hidden` for other reasons, wrap the content in an intermediate container. ## When to Use - Highlighted sections or callout blocks within a padded article layout - Full-bleed background colors or images within a constrained content container - Visual emphasis sections that need to stand out while keeping text aligned - Alternating background bands within a single-column layout --- # CSS Modules Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/architecture/css-modules-strategy ## The Problem CSS operates in a global namespace by default. Every class name you write is available everywhere, which means any stylesheet can accidentally overwrite another's styles. In a growing project, naming collisions become inevitable — two developers independently create a `.title` class, or a new feature's `.container` conflicts with an existing one. Dead CSS accumulates because no one can safely remove a class without searching the entire codebase to confirm it is unused. For AI agents, global CSS is particularly hazardous. An agent generating a `.card` or `.header` class has no way to know whether those names already exist in the project. The result is unintended style overrides that are difficult to diagnose. ## The Solution CSS Modules solve the global namespace problem at build time. Each CSS file is treated as a local scope — class names are automatically transformed into unique identifiers (typically by appending a hash) so they cannot collide with classes from other files. You import styles as a JavaScript object and reference them by key: ```jsx function Button() { return Click me; } ``` The build tool (Webpack, Vite, etc.) transforms `.primary` into something like `.Button_primary_x7f2a`, ensuring uniqueness without any naming convention. The mapping between your authored name and the generated name is handled automatically through the imported `styles` object. ## Code Examples ### How Class Name Scoping Works When you write a CSS Modules file, the build tool transforms each class name into a unique, hashed version. The original names you write are for readability — the browser sees only the generated names. What you write (authored CSS) .title { color: #1e293b; font-size: 1.5rem; } .subtitle { color: #64748b; font-size: 1rem; } .highlight { background: #fef08a; padding: 2px 6px; } What the browser receives (generated CSS) .Card_title_x7f2a { color: #1e293b; font-size: 1.5rem; } .Card_subtitle_k9m3p { color: #64748b; font-size: 1rem; } .Card_highlight_q2w8r { background: #fef08a; padding: 2px 6px; } Title styled with scoped class Subtitle with its own scoped class Text with highlighted word `} css={`* { box-sizing: border-box; margin: 0; } .scope { padding: 16px; font-family: system-ui, sans-serif; } .scope__heading { font-size: 13px; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 12px 0 6px; } .scope__heading:first-child { margin-top: 0; } .scope__code { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px; font-size: 13px; font-family: "SF Mono", Monaco, monospace; line-height: 1.6; overflow-x: auto; white-space: pre; } .scope__code--generated { background: #f0fdf4; border-color: #bbf7d0; } .scope__demo { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; padding: 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; } .title { color: #1e293b; font-size: 1.25rem; font-weight: 600; } .subtitle { color: #64748b; font-size: 0.875rem; } .highlight { background: #fef08a; padding: 2px 6px; border-radius: 3px; }`} height={370} /> ### Basic Usage: Import and Apply In a CSS Modules workflow, you import the CSS file as a JavaScript module. The imported object maps your authored class names to the generated unique names. ```jsx // Button.module.css // .primary { background: #3b82f6; color: #fff; } // .secondary { background: #e2e8f0; color: #1e293b; } function Button({ variant = 'primary', children }) { return ( {children} ); } ``` Primary Action Secondary Delete Each class is scoped: Button_primary_x7f2a instead of .primary `} css={`* { box-sizing: border-box; margin: 0; } .button-demo { padding: 20px; font-family: system-ui, sans-serif; } .button-demo__group { display: flex; gap: 12px; margin-bottom: 12px; } .button-demo__note { font-size: 13px; color: #64748b; } .button-demo__note code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 12px; font-family: "SF Mono", Monaco, monospace; } .Button_primary_x7f2a { background: #3b82f6; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_primary_x7f2a:hover { background: #2563eb; } .Button_secondary_k9m3p { background: #e2e8f0; color: #1e293b; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_secondary_k9m3p:hover { background: #cbd5e1; } .Button_danger_q2w8r { background: #ef4444; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_danger_q2w8r:hover { background: #dc2626; }`} height={120} /> ### No Collisions Across Components The key benefit of CSS Modules is that the same class name in different files produces different generated names. Two components can both use `.title` without any conflict. Card component Card Title Card description text styled by Card.module.css .Card_title_d4e5f Modal component Modal Title Modal description text styled by Modal.module.css .Modal_title_m3n4o `} css={`* { box-sizing: border-box; margin: 0; } .collision-demo { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .collision-demo__panel { flex: 1; display: flex; flex-direction: column; gap: 8px; } .collision-demo__label { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .collision-demo__class { font-size: 12px; font-family: "SF Mono", Monaco, monospace; color: #16a34a; background: #f0fdf4; padding: 4px 8px; border-radius: 4px; display: block; } /* Card component styles */ .Card_wrapper_a1b2c { padding: 16px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; } .Card_title_d4e5f { font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 4px; } .Card_text_g7h8i { font-size: 14px; color: #64748b; line-height: 1.5; } /* Modal component styles */ .Modal_wrapper_j1k2l { padding: 16px; background: #fefce8; border: 2px solid #facc15; border-radius: 8px; } .Modal_title_m3n4o { font-size: 18px; font-weight: 700; color: #854d0e; margin-bottom: 4px; } .Modal_text_p5q6r { font-size: 14px; color: #a16207; line-height: 1.5; }`} height={210} /> ### Composition with composes CSS Modules support a `composes` keyword that lets you combine classes from the same file or from other files. This is the CSS Modules way to share common styles without duplication. ```css /* shared.module.css */ .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; cursor: pointer; } /* Button.module.css */ .primary { composes: baseButton from './shared.module.css'; background: #3b82f6; color: #fff; } ``` Base style (shared.module.css) .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; cursor: pointer; } Composed variants (Button.module.css) .primary { composes: baseButton from './shared.module.css'; background: #3b82f6; color: #fff; } .outline { composes: baseButton from './shared.module.css'; background: transparent; color: #3b82f6; border: 2px solid #3b82f6; } Result: element receives both classes Primary Outline `} css={`* { box-sizing: border-box; margin: 0; } .compose-demo { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; } .compose-demo__section { display: flex; flex-direction: column; gap: 4px; } .compose-demo__label { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .compose-demo__code { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 10px 12px; font-size: 12px; font-family: "SF Mono", Monaco, monospace; line-height: 1.5; overflow-x: auto; white-space: pre; } .compose-demo__buttons { display: flex; gap: 12px; } .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; font-size: 14px; cursor: pointer; } .primary { background: #3b82f6; color: #fff; } .primary:hover { background: #2563eb; } .outline { background: transparent; color: #3b82f6; border: 2px solid #3b82f6; } .outline:hover { background: #eff6ff; }`} height={360} /> ### Global Selectors with :global Sometimes you need to target a class name that is not locally scoped — for example, a third-party library class or a body-level state class. CSS Modules provides `:global()` for this purpose. ```css /* Locally scoped by default */ .container { padding: 16px; } /* Opt out of scoping for specific selectors */ :global(.ReactModal__Overlay) { background: rgba(0, 0, 0, 0.5); } /* Mix local and global */ .container :global(.highlight) { background: yellow; } ``` .container → .App_container_f3g8k Locally scoped (default) .ReactModal__Overlay → .ReactModal__Overlay Global — keeps original name via :global() .App_container_f3g8k .highlight Mixed — local parent, global child This container is scoped. The highlighted text uses a global class inside it. `} css={`* { box-sizing: border-box; margin: 0; } .global-demo { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 10px; } .global-demo__row { display: flex; align-items: center; gap: 12px; } .global-demo__tag { font-size: 12px; font-family: "SF Mono", Monaco, monospace; padding: 4px 10px; border-radius: 4px; white-space: nowrap; } .global-demo__tag--local { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; } .global-demo__tag--global { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } .global-demo__tag--mixed { background: #fefce8; color: #a16207; border: 1px solid #fde68a; } .global-demo__desc { font-size: 13px; color: #64748b; } .global-demo__example { margin-top: 4px; } .App_container_f3g8k { background: #f8fafc; padding: 12px 16px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #334155; line-height: 1.6; } .highlight { background: #fef08a; padding: 1px 6px; border-radius: 3px; font-weight: 500; }`} height={230} /> ## Comparison with Other Approaches | Approach | Scoping Mechanism | Naming | Build Tool Required | Runtime Cost | |---|---|---|---|---| | **CSS Modules** | Build-time hash | Author-chosen, locally scoped | Yes | None | | **BEM** | Manual naming convention | Manual discipline (`block__element--modifier`) | No | None | | **Utility-first** | No custom classes needed | Predefined utility vocabulary | Recommended | None | | **CSS-in-JS** | Runtime or build-time | Co-located with JS components | Depends | Often runtime | - **vs BEM**: BEM achieves collision avoidance through manual naming discipline. CSS Modules automate it — you write `.title` and the tool guarantees uniqueness. BEM is convention, CSS Modules are enforcement. - **vs Utility-first**: Utility frameworks eliminate custom class names entirely. CSS Modules still let you write semantic class names but scope them automatically. The two can coexist — some projects use CSS Modules for component-specific styles and utilities for layout. - **vs CSS-in-JS**: Libraries like styled-components scope styles at runtime by injecting `` tags. CSS Modules achieve the same scoping at build time with zero runtime cost. CSS-in-JS offers dynamic styling based on props; CSS Modules require CSS custom properties or conditional class names for the same. ## Common Mistakes ### Using :global too liberally Wrapping everything in `:global` defeats the purpose of CSS Modules. Reserve it for targeting third-party classes or body-level states — not for convenience. ```css /* Wrong: bypasses scoping for no reason */ :global(.card) { padding: 16px; } :global(.card-title) { font-size: 18px; } /* Correct: use local scoping by default */ .card { padding: 16px; } .cardTitle { font-size: 18px; } ``` ### Not leveraging composes Duplicating shared styles across multiple `.module.css` files when `composes` exists for exactly this purpose. ```css /* Wrong: duplicated base styles */ /* Button.module.css */ .primary { border: none; padding: 10px 20px; border-radius: 6px; background: #3b82f6; color: #fff; } .secondary { border: none; padding: 10px 20px; border-radius: 6px; background: #e2e8f0; color: #1e293b; } /* Correct: compose shared base */ .primary { composes: base from './shared.module.css'; background: #3b82f6; color: #fff; } .secondary { composes: base from './shared.module.css'; background: #e2e8f0; color: #1e293b; } ``` ### Mixing global and module styles in one component Importing both a global stylesheet and a CSS Module for the same component creates ambiguity about which styles are scoped and which are not. ```jsx // Wrong: mixing scoping models // Correct: use one approach consistently per component ``` ### Using kebab-case class names in JavaScript CSS Modules export class names as object properties. Kebab-case names require bracket notation, which is less ergonomic. ```jsx // Awkward: bracket notation needed // Better: use camelCase in .module.css // .cardTitle { font-size: 18px; } ``` ## When to Use CSS Modules work well for: - **React, Vue, and other framework projects** with a build tool (Webpack, Vite) already in place - **Component libraries** where style isolation per component is critical - **Projects migrating from global CSS** that want scoping without adopting CSS-in-JS or a utility framework - **Teams that prefer writing standard CSS** but need automated collision prevention CSS Modules are less suitable when: - **No build tool is available** — CSS Modules require a bundler to transform class names - **You want utility-first styling** — Tailwind or UnoCSS removes the need for custom class names entirely - **You need highly dynamic styles** — CSS-in-JS may be more ergonomic when styles depend on many component props ## References - [CSS Modules — GitHub Repository](https://github.com/css-modules/css-modules) - [css-loader CSS Modules — Webpack Documentation](https://webpack.js.org/loaders/css-loader/#modules) - [CSS Modules — Vite Documentation](https://vite.dev/guide/features.html#css-modules) - [Adding a CSS Modules Stylesheet — Create React App](https://create-react-app.dev/docs/adding-a-css-modules-stylesheet/) --- # Advanced Custom Properties > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/custom-properties-advanced ## The Problem CSS custom properties (variables) are widely used, but most developers and AI agents only scratch the surface — defining simple color or size tokens on `:root` and referencing them with `var()`. Advanced patterns like the space-toggling trick, fallback chains, and computed property relationships are rarely leveraged, leading to unnecessary JavaScript for conditional styling, rigid theming systems, and duplicated declarations. ## The Solution CSS custom properties are far more powerful than simple variable substitution. They participate in the cascade, can be scoped to any element, support multi-level fallback chains, and can be combined with the space-toggling trick to create boolean-like conditional logic — all in pure CSS with zero JavaScript. ## Code Examples ### The Space-Toggling Trick The space toggle exploits how `var()` fallbacks work. A custom property set to a single space (` `) is valid and "passes through," while a property set to `initial` triggers the fallback value. ```css /* The toggle: space = ON, initial = OFF */ .card { --is-featured: initial; /* OFF by default */ /* When ON, value becomes " blue"; when OFF, fallback "gray" is used */ background: var(--is-featured) blue, gray; color: var(--is-featured) white, #333; border-width: var(--is-featured) 3px, 1px; } .card.featured { --is-featured: ; /* ON (single space) */ } ``` ### Space Toggle for Dark Mode ```css :root { --dark: initial; /* Light mode by default */ } @media (prefers-color-scheme: dark) { :root { --dark: ; /* Enable dark mode */ } } body { background: var(--dark) #1a1a2e, #ffffff; color: var(--dark) #e0e0e0, #1a1a2e; } .card { background: var(--dark) #2d2d44, #f5f5f5; border-color: var(--dark) #444, #ddd; } ``` ### Fallback Chains for Theming Create layered configuration systems where a component checks for progressively broader defaults. This is the mechanism behind the [Three-Tier Color Strategy](../../../styling/color/three-tier-color-strategy) — component tokens fall back to theme tokens, which fall back to palette values. ```css .button { /* Check component-specific → theme-level → hardcoded default */ background: var(--button-bg, var(--accent-color, #2563eb)); color: var(--button-color, var(--accent-contrast, white)); padding: var(--button-padding, var(--spacing-sm, 0.5rem 1rem)); border-radius: var(--button-radius, var(--radius, 4px)); } /* Theme-level override: changes all components using --accent-color */ .theme-warm { --accent-color: #ea580c; --accent-contrast: white; } /* Component-specific override: changes only buttons */ .cta-section { --button-bg: #16a34a; --button-color: white; } ``` ### Scoped Custom Properties for Component Variants Instead of creating separate classes for every variant, use custom properties as a styling API. ```css .badge { --_bg: var(--badge-bg, #e5e7eb); --_color: var(--badge-color, #374151); --_size: var(--badge-size, 0.75rem); background: var(--_bg); color: var(--_color); font-size: var(--_size); padding: 0.25em 0.75em; border-radius: 999px; font-weight: 600; } /* Variants set only the custom properties */ .badge-success { --badge-bg: #dcfce7; --badge-color: #166534; } .badge-error { --badge-bg: #fee2e2; --badge-color: #991b1b; } ``` ### Computed Relationships with `calc()` ```css .fluid-type { --min-size: 1; --max-size: 1.5; --min-width: 320; --max-width: 1200; font-size: calc( (var(--min-size) * 1rem) + (var(--max-size) - var(--min-size)) * (100vw - var(--min-width) * 1px) / (var(--max-width) - var(--min-width)) ); } h1 { --min-size: 1.5; --max-size: 3; } h2 { --min-size: 1.25; --max-size: 2; } ``` ### Sharing State Between CSS and JavaScript ```css .progress-bar { --progress: 0; width: calc(var(--progress) * 1%); background: hsl(calc(var(--progress) * 1.2) 70% 50%); transition: width 0.3s, background 0.3s; } ``` ```html // Update from JavaScript — CSS handles the visual mapping element.style.setProperty('--progress', newValue); ``` ### Private Custom Properties Convention Use a leading underscore after `--` to indicate "internal" properties that should not be set by consumers. ```css .tooltip { /* Public API */ --tooltip-bg: var(--surface-inverse, #1f2937); --tooltip-color: var(--text-inverse, white); /* Private (internal computation) */ --_arrow-size: 6px; --_offset: calc(100% + var(--_arrow-size) + 4px); background: var(--tooltip-bg); color: var(--tooltip-color); transform: translateY(calc(-1 * var(--_offset))); } ``` ## Browser Support - Chrome 49+ - Firefox 31+ - Safari 9.1+ - Edge 15+ Custom properties have near-universal support (98%+). The `initial` keyword behavior used in the space-toggling trick works across all browsers that support custom properties. For best performance, avoid fallback chains deeper than 3 levels, and scope `setProperty()` calls to the most specific element rather than `:root`. ## Common AI Mistakes - Using JavaScript to toggle visual states that could be handled with the space-toggling trick - Only defining custom properties on `:root` instead of scoping them to components - Not leveraging fallback chains for themeable component APIs - Creating separate CSS classes for every variant instead of using custom property-based variants - Using raw values in `calc()` without custom properties, making the relationship between values opaque - Deeply nesting fallback chains (4+ levels) which adds resolution overhead - Not using a naming convention (like `--_` prefix) to distinguish public vs private custom properties ## When to Use - Theming systems with component-level overrides via fallback chains - Boolean-like conditional styling with the space-toggling trick (dark mode, feature flags) - Component variant APIs where consumers set properties to customize appearance - Computed relationships between values (responsive sizing, color palettes) - Bridging CSS and JavaScript state without class toggling ## Live Previews Regular Card Default styling — --is-featured is off (initial) Featured Card Featured styling — --is-featured is on (space value) Regular Card Back to default styling The .featured class sets --is-featured to a space value, toggling multiple properties at once with zero JavaScript `} css={` .cards { font-family: system-ui, sans-serif; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .card { --is-featured: initial; background: var(--is-featured) linear-gradient(135deg, #fbbf24, #f59e0b), #f8fafc; color: var(--is-featured) #78350f, #334155; border: var(--is-featured) 2px solid #f59e0b, 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem; transition: transform 0.2s; } .card.featured { --is-featured: ; transform: scale(1.05); box-shadow: 0 8px 24px rgba(245, 158, 11, 0.25); } .card h3 { margin: 0 0 0.5rem; font-size: 1rem; } .card p { margin: 0; font-size: 0.85rem; opacity: 0.8; line-height: 1.5; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> Default Success Error Info Each variant sets only custom properties — the base .badge rule handles all rendering `} css={` .demo { font-family: system-ui, sans-serif; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .badge { --_bg: var(--badge-bg, #f1f5f9); --_color: var(--badge-color, #475569); --_border: var(--badge-border, #e2e8f0); background: var(--_bg); color: var(--_color); border: 1px solid var(--_border); padding: 0.35rem 1rem; border-radius: 999px; font-weight: 600; font-size: 0.875rem; } .badge-success { --badge-bg: #dcfce7; --badge-color: #166534; --badge-border: #86efac; } .badge-error { --badge-bg: #fee2e2; --badge-color: #991b1b; --badge-border: #fca5a5; } .badge-info { --badge-bg: #dbeafe; --badge-color: #1e40af; --badge-border: #93c5fd; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} /> ## Deep Dive - [Pattern Catalog](./pattern-catalog) — Comprehensive collection of CSS custom property patterns with interactive demos - [Theming Recipes](./theming-recipes) — Complete theme system recipes for light/dark mode, brand theming, and component libraries ## References - [Using CSS custom properties (variables) - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascading_variables/Using_custom_properties) - [The CSS Custom Property Toggle Trick - CSS-Tricks](https://css-tricks.com/the-css-custom-property-toggle-trick/) - [The --var: ; hack to toggle multiple values with one custom property - Lea Verou](https://lea.verou.me/blog/2020/10/the-var-space-hack-to-toggle-multiple-values-with-one-custom-property/) - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Cyclic Dependency Space Toggles - kizu.dev](https://kizu.dev/cyclic-toggles/) --- # Multi Namespace Token Strategy > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/multi-namespace-token-strategy ## The Problem A website starts small — a blog with article pages. The design team creates tokens under a single namespace: ```css @theme { /* myweb- namespace: article-focused design */ --spacing-myweb-hsp-sm: 20px; --spacing-myweb-hsp-md: 40px; --spacing-myweb-vsp-sm: 24px; --spacing-myweb-vsp-md: 48px; --font-size-myweb-body: 1.125rem; --font-size-myweb-h1: 2.5rem; --font-size-myweb-h2: 1.75rem; --font-size-myweb-caption: 0.875rem; } ``` This works. Spacing is generous for readability. Font sizes are optimized for long-form content. Every component shares the same design language. Then the site grows. A login page arrives. Then a user settings panel. Then an admin dashboard with data tables, dense toolbars, and compact navigation. The admin UI needs tighter spacing, smaller font sizes, and a denser layout — the opposite of what the article tokens provide. The instinct is to add more tokens to the same namespace: ```css @theme { /* Original article tokens */ --spacing-myweb-hsp-sm: 20px; --spacing-myweb-hsp-md: 40px; --spacing-myweb-vsp-sm: 24px; --spacing-myweb-vsp-md: 48px; /* ...now add admin-density tokens into the same namespace */ --spacing-myweb-hsp-dense-sm: 8px; --spacing-myweb-hsp-dense-md: 16px; --spacing-myweb-vsp-dense-sm: 6px; --spacing-myweb-vsp-dense-md: 12px; --font-size-myweb-body: 1.125rem; --font-size-myweb-body-dense: 0.8125rem; --font-size-myweb-h1: 2.5rem; --font-size-myweb-h1-dense: 1.25rem; --font-size-myweb-h2: 1.75rem; --font-size-myweb-h2-dense: 1rem; --font-size-myweb-caption: 0.875rem; --font-size-myweb-caption-dense: 0.75rem; } ``` The token set doubles in size. Every axis now has both "article" and "dense" variants. Developers must choose between `hsp-sm` and `hsp-dense-sm` for every spacing decision. The tight token strategy — designed to constrain choices — now offers twice as many options as before. As the site adds more contexts (marketing landing pages, email templates, embedded widgets), the problem compounds. A single namespace tries to serve every design context, and the token set becomes a bloated list of variations. Single namespace — all contexts mixed myweb- Spacing tokens hsp-sm: 20px hsp-md: 40px vsp-sm: 24px vsp-md: 48px hsp-dense-sm: 8px hsp-dense-md: 16px vsp-dense-sm: 6px vsp-dense-md: 12px Font-size tokens body: 1.125rem h1: 2.5rem h2: 1.75rem caption: 0.875rem body-dense: 0.8125rem h1-dense: 1.25rem h2-dense: 1rem caption-dense: 0.75rem 8 spacing tokens + 8 font tokens = 16 total. Each new context doubles the count. `} css={`.bloat-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .bloat-demo__section { display: flex; align-items: center; gap: 0.5rem; } .bloat-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .bloat-demo__namespace { font-size: 0.75rem; font-weight: 700; background: hsl(215 16% 90%); color: hsl(215 16% 35%); padding: 2px 8px; border-radius: 4px; } .bloat-demo__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .bloat-demo__col { display: flex; flex-direction: column; gap: 0.35rem; } .bloat-demo__col-label { font-size: 0.75rem; font-weight: 600; color: hsl(215 16% 47%); } .bloat-demo__tokens { display: flex; flex-direction: column; gap: 3px; } .bloat-demo__token { font-size: 0.75rem; padding: 2px 6px; border-radius: 3px; } .bloat-demo__token--article { background: hsl(210 40% 93%); color: hsl(210 50% 35%); border: 1px solid hsl(210 30% 82%); } .bloat-demo__token--admin { background: hsl(30 70% 93%); color: hsl(30 60% 30%); border: 1px solid hsl(30 50% 80%); } .bloat-demo__verdict { font-size: 0.7rem; color: hsl(0 60% 40%); background: hsl(0 80% 95%); padding: 0.4rem 0.6rem; border-radius: 6px; border-left: 3px solid hsl(0 60% 50%); line-height: 1.4; }`} /> ## The Solution Split tokens into **separate namespaces**, one per design context. Each namespace contains a focused, minimal token set that serves exactly one type of UI. ```css /* ── Article/content context ── */ @theme { --spacing-myweb-hsp-sm: 20px; --spacing-myweb-hsp-md: 40px; --spacing-myweb-vsp-sm: 24px; --spacing-myweb-vsp-md: 48px; --font-size-myweb-body: 1.125rem; --font-size-myweb-h1: 2.5rem; --font-size-myweb-h2: 1.75rem; --font-size-myweb-caption: 0.875rem; } /* ── Admin/dashboard context ── */ @theme { --spacing-myadmin-hsp-sm: 8px; --spacing-myadmin-hsp-md: 16px; --spacing-myadmin-vsp-sm: 6px; --spacing-myadmin-vsp-md: 12px; --font-size-myadmin-body: 0.8125rem; --font-size-myadmin-h1: 1.25rem; --font-size-myadmin-h2: 1rem; --font-size-myadmin-caption: 0.75rem; } ``` The total token count is the same, but the structure is different: - Each namespace has exactly 4 spacing + 4 font-size tokens — the same tight constraint as a single-context project - Developers working on article pages only interact with `myweb-` tokens - Developers working on the admin dashboard only interact with `myadmin-` tokens - There is no ambiguity about which token to use — the namespace tells you the context ### File Organization Separate namespaces work best when each lives in its own CSS file: ``` styles/ ├── tokens-myweb.css /* Article/content tokens */ ├── tokens-myadmin.css /* Admin/dashboard tokens */ ├── tokens-shared.css /* Color, font-family, breakpoints — shared */ └── app.css /* Imports all token files */ ``` Shared tokens (colors, font families, breakpoints) that apply across all contexts stay in a shared file. Only context-specific tokens (spacing, font sizes) are split into namespaces. ```css /* app.css */ @import "tailwindcss/preflight"; @import "tailwindcss/utilities"; @import "./tokens-shared.css"; @import "./tokens-myweb.css"; @import "./tokens-myadmin.css"; ``` Separated namespaces — each context is focused myweb- Article / Content hsp-sm: 20px hsp-md: 40px vsp-sm: 24px vsp-md: 48px body: 1.125rem h1: 2.5rem h2: 1.75rem caption: 0.875rem 8 tokens — clean, focused myadmin- Admin / Dashboard hsp-sm: 8px hsp-md: 16px vsp-sm: 6px vsp-md: 12px body: 0.8125rem h1: 1.25rem h2: 1rem caption: 0.75rem 8 tokens — clean, focused Shared across both colors font-family breakpoints Same total tokens, but each context stays tight and unambiguous. `} css={`.sep-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .sep-demo__section { display: flex; align-items: center; gap: 0.5rem; } .sep-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .sep-demo__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .sep-demo__ns { border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } .sep-demo__ns-header { padding: 0.35rem 0.6rem; display: flex; align-items: center; gap: 0.5rem; } .sep-demo__ns-header--article { background: hsl(210 40% 93%); } .sep-demo__ns-header--admin { background: hsl(30 60% 93%); } .sep-demo__ns-name { font-size: 0.7rem; font-weight: 700; font-family: monospace; } .sep-demo__ns-desc { font-size: 0.75rem; color: hsl(215 16% 47%); } .sep-demo__tokens { padding: 0.4rem 0.6rem; display: flex; flex-direction: column; gap: 2px; } .sep-demo__token { font-size: 0.75rem; padding: 1px 5px; border-radius: 3px; } .sep-demo__token--article { background: hsl(210 40% 96%); color: hsl(210 50% 35%); } .sep-demo__token--admin { background: hsl(30 60% 96%); color: hsl(30 60% 30%); } .sep-demo__token--shared { background: hsl(270 30% 94%); color: hsl(270 30% 35%); } .sep-demo__count { padding: 0.25rem 0.6rem; font-size: 0.75rem; color: hsl(142 50% 30%); background: hsl(142 50% 95%); border-top: 1px solid hsl(214 32% 91%); } .sep-demo__shared { display: flex; align-items: center; gap: 0.5rem; } .sep-demo__shared-label { font-size: 0.75rem; color: hsl(215 16% 47%); font-weight: 600; } .sep-demo__shared-tokens { display: flex; gap: 4px; } .sep-demo__verdict { font-size: 0.7rem; color: hsl(142 50% 30%); background: hsl(142 50% 93%); padding: 0.4rem 0.6rem; border-radius: 6px; border-left: 3px solid hsl(142 50% 40%); line-height: 1.4; }`} /> ## Demos ### Visual Contrast: Article vs Admin UI The same page framework rendered with two different token namespaces. Article tokens produce generous, readable spacing. Admin tokens produce a dense, efficient layout. myweb- tokens Getting Started This guide walks through the initial setup process for new users. Guide Setup Updated 2 days ago Generous spacing for reading comfort myadmin- tokens User Management NameRoleStatus AliceAdminActive BobEditorActive CarolViewerInactive 3 users total Tight spacing for data density `} css={`.ctx-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .ctx-demo__col { display: flex; flex-direction: column; gap: 0.4rem; } .ctx-demo__heading { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); font-family: monospace; } .ctx-demo__card { border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } /* ── Article context ── */ .ctx-demo__card-header--article { padding: 24px 20px 0; } .ctx-demo__title--article { font-size: 1.25rem; font-weight: 700; margin: 0; } .ctx-demo__card-body--article { padding: 24px 20px; } .ctx-demo__text--article { font-size: 0.9rem; line-height: 1.7; margin: 0 0 16px 0; color: hsl(215 16% 35%); } .ctx-demo__meta--article { display: flex; gap: 12px; } .ctx-demo__tag--article { font-size: 0.75rem; background: hsl(210 40% 93%); color: hsl(210 50% 35%); padding: 4px 12px; border-radius: 4px; } .ctx-demo__card-footer--article { padding: 12px 20px; border-top: 1px solid hsl(214 32% 91%); background: hsl(210 20% 98%); } .ctx-demo__caption--article { font-size: 0.8rem; color: hsl(215 16% 55%); } /* ── Admin context ── */ .ctx-demo__card-header--admin { padding: 6px 8px 0; } .ctx-demo__title--admin { font-size: 0.85rem; font-weight: 700; margin: 0; } .ctx-demo__card-body--admin { padding: 6px 8px; } .ctx-demo__table { display: flex; flex-direction: column; font-size: 0.7rem; } .ctx-demo__row { display: grid; grid-template-columns: 1fr 1fr 1fr; padding: 3px 0; border-bottom: 1px solid hsl(214 32% 93%); } .ctx-demo__row--header { font-weight: 700; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; color: hsl(215 16% 47%); } .ctx-demo__status { font-size: 0.75rem; font-weight: 600; } .ctx-demo__status--active { color: hsl(142 50% 35%); } .ctx-demo__status--inactive { color: hsl(0 50% 50%); } .ctx-demo__card-footer--admin { padding: 4px 8px; border-top: 1px solid hsl(214 32% 91%); background: hsl(210 20% 98%); } .ctx-demo__caption--admin { font-size: 0.75rem; color: hsl(215 16% 55%); } .ctx-demo__note { font-size: 0.75rem; color: hsl(215 16% 47%); font-style: italic; }`} /> ### When to Split: Decision Boundary Not every page variation needs its own namespace. The split makes sense when design rules — spacing rhythm, font size scale, density — are fundamentally different between contexts. Same namespace is fine ✓ Blog + About page Same reading context, same spacing ✓ Product list + Product detail Same design language, same density ✓ Homepage + Contact page Same visual system throughout Separate namespace helps ▶ Blog + Admin dashboard Different density, different font scale ▶ Marketing site + App UI Marketing: generous. App: compact. ▶ Docs + Data-heavy tools Reading vs scanning — opposite needs `} css={`.split-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .split-demo__col { display: flex; flex-direction: column; gap: 0.4rem; } .split-demo__heading { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .split-demo__card { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.5rem; display: flex; flex-direction: column; gap: 8px; } .split-demo__item { display: flex; gap: 8px; align-items: flex-start; } .split-demo__check { width: 18px; height: 18px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; flex-shrink: 0; margin-top: 1px; } .split-demo__check--same { background: hsl(210 40% 93%); color: hsl(210 50% 40%); } .split-demo__check--split { background: hsl(30 60% 93%); color: hsl(30 60% 35%); } .split-demo__desc { min-width: 0; } .split-demo__item-title { font-size: 0.7rem; font-weight: 600; } .split-demo__item-reason { font-size: 0.75rem; color: hsl(215 16% 55%); line-height: 1.3; }`} /> ### Namespace Usage in Components Each namespace produces its own set of Tailwind utility classes. Developers working in a specific context use only that context's namespace prefix. Article page component <article class="px-myweb-hsp-sm py-myweb-vsp-md"> <h1 class="text-myweb-h1">Title</h1> <p class="text-myweb-body">Content...</p> </article> Admin dashboard component <div class="px-myadmin-hsp-sm py-myadmin-vsp-sm"> <h2 class="text-myadmin-h2">Users</h2> <table class="text-myadmin-body">...</table> </div> The namespace prefix acts as built-in documentation — you always know which design context you are in. `} css={`.usage-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .usage-demo__section { display: flex; flex-direction: column; gap: 0.3rem; } .usage-demo__label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .usage-demo__code { background: hsl(222 47% 11%); color: hsl(210 20% 80%); padding: 0.5rem 0.6rem; border-radius: 6px; display: flex; flex-direction: column; gap: 2px; } .usage-demo__code code { font-size: 0.75rem; font-family: monospace; line-height: 1.5; white-space: pre; } .usage-demo__hl { font-weight: 700; } .usage-demo__hl--article { color: hsl(210 80% 70%); } .usage-demo__hl--admin { color: hsl(30 80% 70%); } .usage-demo__note { font-size: 0.75rem; color: hsl(142 50% 30%); background: hsl(142 50% 93%); padding: 0.4rem 0.6rem; border-radius: 6px; border-left: 3px solid hsl(142 50% 40%); line-height: 1.4; }`} /> ## Shared vs Context-Specific Tokens Not all token categories need namespace separation. The rule: split tokens that define **density and scale** (spacing, font sizes). Keep tokens that define **identity** (colors, font families, border radii) shared. | Token category | Split or shared? | Why | | --- | --- | --- | | Spacing (hsp, vsp) | Split per context | Different contexts need different density | | Font sizes | Split per context | Article text vs dashboard text have different scales | | Colors | Shared | Brand identity stays consistent across contexts | | Font families | Shared | Typography choice is a brand decision | | Border radii | Shared | Visual style is consistent across contexts | | Breakpoints | Shared | Responsive behavior follows the same viewport rules | ```css /* tokens-shared.css — applies everywhere */ @theme { --color-brand: oklch(55.5% 0.163 48.998); --color-surface: hsl(0 0% 100%); --color-text: hsl(222 47% 11%); --color-muted: hsl(215 16% 47%); --color-border: hsl(214 32% 91%); --font-sans: system-ui, sans-serif; --font-mono: ui-monospace, monospace; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; } ``` ## Quick Reference | Scenario | Approach | | --- | --- | | Single design context (blog, marketing site) | One namespace — no split needed | | Two distinct UI densities (content + admin) | Two namespaces, one per context | | Three or more contexts (content + admin + embedded widget) | One namespace per context, shared tokens in a common file | | Colors, font families, border radii | Keep in shared namespace — these define identity, not density | | Spacing and font sizes | Split per context — these define density and scale | | Team ownership boundaries (frontend team vs admin team) | Align namespaces with team boundaries for clear ownership | ## Common AI Mistakes - **Mixing tokens from different namespaces in one component** — if a component uses `px-myweb-hsp-sm` for horizontal padding and `text-myadmin-body` for font size, it is pulling from two design contexts; each component should use tokens from exactly one namespace - **Creating a namespace for every page** — namespaces represent design contexts (content vs admin), not individual pages; a blog post and an about page share the same design context - **Splitting colors into namespaces** — colors define brand identity and should remain shared; only density-related tokens (spacing, font sizes) need namespace separation - **Using generic token names without a namespace prefix** — in a multi-namespace project, `hsp-sm` is ambiguous; always use the full prefix (`myweb-hsp-sm` or `myadmin-hsp-sm`) - **Creating namespaces preemptively** — start with one namespace; split only when a genuinely different design context arrives with different density requirements ## When to Use ### Good fit - **Sites with distinct UI contexts** — a content-heavy site that adds an admin dashboard, or a marketing site that adds an application UI - **Large teams with separate ownership** — when the article team and the admin team work independently, separate namespaces prevent cross-contamination of design decisions - **Projects using the tight token strategy** — namespace separation is a natural extension when the single namespace accumulates too many variants ### Not needed - **Single-context websites** — a blog, a documentation site, or a portfolio does not need multiple namespaces - **Small projects** — if the token set is small and manageable, the complexity of multiple namespaces adds overhead without benefit - **Projects early in development** — start with one namespace and split later when a second design context actually arrives ### Contrast with Other Token Strategies This strategy builds on top of the tight token strategy. It does not replace it — it extends it for multi-context projects. | Strategy | Scope | When | | --- | --- | --- | | [Tight Token Strategy](./tight-token-strategy/) | Single namespace, constrained tokens | Default for all projects | | [Two-Tier Size Strategy](./two-tier-size-strategy/) | Theme tokens + arbitrary values for width/height | When sizing elements within any namespace | | **Multi Namespace Token Strategy** | Multiple namespaces for different UI contexts | When a project serves fundamentally different design contexts | ## References - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # Token Preview > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/tight-token-strategy/token-preview A visual reference of all available tokens in the tight token strategy. Use this page as a quick cheat sheet when choosing spacing, color, or typography values. ## Spacing Tokens ### Horizontal Spacing (hsp) hsp-2xs 5px hsp-xs 12px hsp-sm 20px hsp-md 40px hsp-lg 60px hsp-xl 100px hsp-2xl 250px `} css={`.token-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .token-row { display: flex; align-items: center; gap: 12px; } .token-name { font-size: 13px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); width: 72px; flex-shrink: 0; } .token-value { font-size: 12px; color: hsl(215 16% 47%); width: 40px; flex-shrink: 0; text-align: right; } .token-bar { height: 24px; background: hsl(221 83% 53% / 0.2); border-left: 3px solid hsl(221 83% 53%); border-radius: 0 4px 4px 0; }`} /> ### Vertical Spacing (vsp) vsp-2xs 4px vsp-xs 8px vsp-sm 20px vsp-md 35px vsp-lg 50px vsp-xl 65px vsp-2xl 80px `} css={`.token-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 8px; } .token-row { display: flex; align-items: flex-start; gap: 16px; } .token-info { display: flex; align-items: baseline; gap: 8px; width: 130px; flex-shrink: 0; padding-top: 2px; } .token-name { font-size: 13px; font-family: monospace; font-weight: 600; color: hsl(142 71% 35%); } .token-value { font-size: 12px; color: hsl(215 16% 47%); } .token-bar-wrap { flex: 1; } .token-bar { width: 100%; background: hsl(142 71% 45% / 0.2); border-top: 3px solid hsl(142 71% 45%); border-radius: 0 0 4px 4px; }`} /> ## Color Tokens ### Brand Colors Primary primary-light hsl(217 91% 60%) primary hsl(221 83% 53%) primary-dark hsl(224 76% 48%) Secondary secondary-light hsl(250 80% 68%) secondary hsl(252 78% 60%) secondary-dark hsl(255 70% 52%) Accent accent-light hsl(38 95% 64%) accent hsl(33 95% 54%) accent-dark hsl(28 90% 46%) `} css={`.swatch-grid { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; } .swatch-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 6px; } .swatch-row { display: flex; gap: 6px; } .swatch { flex: 1; padding: 12px 10px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.accent { color: hsl(222 47% 11%); } .swatch-name { font-size: 12px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.8; font-family: monospace; }`} /> ### State Colors success hsl(142 71% 45%) warning hsl(38 92% 50%) error hsl(0 84% 60%) info hsl(199 89% 48%) `} css={`.swatch-row { display: flex; gap: 6px; padding: 16px; font-family: system-ui, sans-serif; } .swatch { flex: 1; padding: 14px 12px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.dark-text { color: hsl(222 47% 11%); } .swatch-name { font-size: 13px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.8; font-family: monospace; }`} /> ### Surface, Text, and Border Colors Surface surface hsl(0 0% 100%) surface-alt hsl(210 40% 96%) surface-inverse hsl(222 47% 11%) Text The quick brown fox text — hsl(222 47% 11%) The quick brown fox text-muted — hsl(215 16% 47%) The quick brown fox text-inverse — hsl(210 40% 98%) Border border — hsl(214 32% 91%) border-focus — hsl(221 83% 53%) `} css={`.token-section { padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 16px; } .group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 6px; } .swatch-row { display: flex; gap: 6px; } .swatch { flex: 1; padding: 14px 12px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.surface-light { color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); } .swatch-name { font-size: 12px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.7; font-family: monospace; } /* Text samples */ .text-samples { display: flex; flex-direction: column; gap: 4px; } .text-sample { display: flex; align-items: baseline; gap: 12px; padding: 8px 12px; border-radius: 6px; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); } .text-sample.dark { background: hsl(222 47% 11%); } .text-preview { font-size: 14px; font-weight: 500; flex-shrink: 0; } .text-meta { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); margin-left: auto; } .text-sample.dark .text-meta { color: hsl(215 25% 65%); } /* Border samples */ .border-samples { display: flex; gap: 12px; } .border-sample { flex: 1; display: flex; align-items: center; gap: 10px; } .border-box { width: 60px; height: 36px; border: 2px solid; border-radius: 6px; flex-shrink: 0; } .border-meta { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); }`} /> ## Typography Tokens ### Font Sizes caption 0.75rem (12px) The quick brown fox jumps over the lazy dog body 1rem (16px) The quick brown fox jumps over the lazy dog subheading 1.25rem (20px) The quick brown fox jumps over the lazy dog heading 1.75rem (28px) The quick brown fox jumps display 2.5rem (40px) The quick brown fox `} css={`.type-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 8px; } .type-row { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; border-bottom: 1px solid hsl(214 32% 91%); } .type-row:last-child { border-bottom: none; } .type-meta { display: flex; align-items: baseline; gap: 8px; } .type-name { font-size: 11px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .type-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; } .type-sample { line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }`} /> ### Font Weights The quick brown fox jumps over the lazy dog normal — 400 The quick brown fox jumps over the lazy dog medium — 500 The quick brown fox jumps over the lazy dog bold — 700 `} css={`.weight-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .weight-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .weight-sample { font-size: 16px; line-height: 1.4; } .weight-meta { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; flex-shrink: 0; }`} /> ### Line Heights tight 1.25 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. normal 1.5 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. relaxed 1.75 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. `} css={`.lh-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 12px; } .lh-row { display: flex; gap: 16px; align-items: flex-start; } .lh-meta { width: 80px; flex-shrink: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 2px; } .lh-name { font-size: 12px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .lh-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; } .lh-sample { font-size: 13px; margin: 0; flex: 1; color: hsl(222 47% 11%); background: hsl(210 40% 96%); padding: 8px 12px; border-radius: 6px; }`} /> ### Font Families sans The quick brown fox jumps over the lazy dog — 0123456789 "Inter", system-ui, sans-serif mono The quick brown fox jumps — 0123456789 "JetBrains Mono", ui-monospace, monospace `} css={`.ff-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .ff-row { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px; border: 1px solid hsl(214 32% 91%); border-radius: 6px; } .ff-name { font-size: 11px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .ff-sample { font-size: 15px; line-height: 1.4; } .ff-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; }`} /> --- # css-wisdom Skill > Source: https://takazudomodular.com/pj/zcss/docs/overview/css-wisdom-skill The `css-wisdom` skill is a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) skill that indexes all CSS best practices articles in this documentation site. It enables AI coding agents to quickly look up relevant CSS patterns and techniques during development. ## What It Does The skill maintains a topic index that maps CSS concepts to their documentation articles. When invoked, it reads the relevant article and applies the recommended patterns. The topic index is generated from all MDX articles under `src/content/docs/` (excluding `overview/` and `inbox/` categories), combined with curated descriptions from `.claude/skills/css-wisdom/descriptions.json`. ## Installation The skill must be symlinked to your global Claude Code skills directory: ```bash pnpm run setup:symlink ``` This runs the topic index generator and creates a symlink at `~/.claude/skills/css-wisdom` pointing to this repo's `.claude/skills/css-wisdom/`. ## Usage In any Claude Code session, invoke the skill with a topic keyword: ``` /css-wisdom flexbox /css-wisdom dark mode /css-wisdom centering ``` The skill will find the relevant article(s) from the topic index, read them, and apply the CSS patterns when writing code. ## Regenerating the Topic Index When articles are added or removed, regenerate the topic index: ```bash pnpm run generate:css-wisdom ``` The generator script (`scripts/generate-css-wisdom.js`) reads all MDX files under `src/content/docs/`, looks up their descriptions in `descriptions.json`, and produces the `SKILL.md` topic index. When adding a new article, also add its description to `.claude/skills/css-wisdom/descriptions.json` before running the generator. ## Skill Structure ``` .claude/skills/css-wisdom/ SKILL.md # Generated topic index (do not edit manually) descriptions.json # Curated article descriptions (edit this) ``` --- # Responsive Images > Source: https://takazudomodular.com/pj/zcss/docs/responsive/responsive-images ## The Problem Images are one of the most common sources of layout issues and performance problems. AI agents frequently output `` tags with fixed widths, forget `object-fit` (causing stretched or squished images), omit `aspect-ratio` (causing layout shift), and rarely generate proper `srcset`/`sizes` attributes or `` elements for art direction. ## The Solution Responsive images require both CSS techniques (`object-fit`, `aspect-ratio`) for visual presentation and HTML attributes (`srcset`, `sizes`, ``) for performance and art direction. cover Fills the box, cropping to maintain ratio contain Fits entirely inside, letterboxing if needed fill Stretches to fill — distorts the image `} css={` .fit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; padding: 1rem; } .fit-item { text-align: center; } .fit-label { font-size: 0.8125rem; font-weight: 700; color: #1e293b; margin-bottom: 0.5rem; font-family: monospace; background: #f1f5f9; padding: 0.25rem 0.5rem; border-radius: 0.25rem; display: inline-block; } .fit-image { width: 100%; height: 120px; border-radius: 0.375rem; border: 2px solid #e2e8f0; background-image: linear-gradient(135deg, #ef4444 0%, #f59e0b 25%, #22c55e 50%, #3b82f6 75%, #8b5cf6 100%); background-size: 200% 200%; } .fit-cover { object-fit: cover; background-size: cover; } .fit-contain { background-size: contain; background-repeat: no-repeat; background-position: center; } .fit-fill { background-size: 100% 100%; } .fit-desc { font-size: 0.75rem; color: #64748b; margin: 0.5rem 0 0; line-height: 1.4; } `} /> ## Code Examples ### Preventing Stretched Images with object-fit The `object-fit` property controls how an image fills its container, similar to `background-size` for background images. ```css /* Fills the container, cropping to maintain aspect ratio */ .image-cover { width: 100%; height: 300px; object-fit: cover; } /* Fits entirely within the container, letterboxing if needed */ .image-contain { width: 100%; height: 300px; object-fit: contain; } ``` ### Controlling Crop Position with object-position ```css /* Focus on the top of the image when cropping */ .image-top { width: 100%; height: 200px; object-fit: cover; object-position: center top; } /* Focus on a specific area */ .image-focal { width: 100%; height: 200px; object-fit: cover; object-position: 30% 20%; } ``` ### Preventing Layout Shift with aspect-ratio ```css .card__image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } .avatar { width: 3rem; aspect-ratio: 1; object-fit: cover; border-radius: 50%; } .hero-image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; } ``` ### Basic Responsive Image with max-width ```css img { max-width: 100%; height: auto; } ``` This is the minimum CSS every project should apply to images. It prevents images from overflowing their container while maintaining aspect ratio. ### Resolution Switching with srcset and sizes Use `srcset` to provide multiple image sizes and `sizes` to tell the browser how wide the image will be rendered at different viewport widths. ```html ``` - `srcset` lists available image files and their intrinsic widths. - `sizes` describes how wide the image will be in the layout at different viewport widths. - The browser picks the optimal file based on viewport width and device pixel ratio. - `width` and `height` attributes provide the intrinsic dimensions for aspect ratio calculation before the image loads. ### Art Direction with the picture Element Use `` when you need to serve a completely different image (different crop, different content) at different viewport widths. ```html ``` ### Format Switching with picture Serve modern formats with fallbacks: ```html ``` ### Complete Responsive Image Pattern Combining all techniques: ```html ``` ```css picture img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; } ``` ## Common AI Mistakes - **Forgetting `object-fit`**: Setting a fixed `width` and `height` on an image without `object-fit: cover`, resulting in stretched or squished images. - **No `aspect-ratio`**: Omitting `aspect-ratio` causes layout shift (CLS) when the image loads. - **Missing `width` and `height` attributes**: These HTML attributes let the browser calculate the aspect ratio before the image loads, preventing layout shift. - **No `srcset` or `sizes`**: Serving a single large image file to all devices wastes bandwidth on mobile. - **Incorrect `sizes` values**: Using `sizes="100vw"` when the image only takes up a portion of the viewport, causing the browser to download an oversized file. - **Using `background-image` for content images**: Content images should use `` for accessibility (alt text) and performance (lazy loading). Reserve `background-image` for decorative images. - **Forgetting `loading="lazy"`**: Images below the fold should use `loading="lazy"` to defer loading. However, do not lazy-load the LCP (Largest Contentful Paint) image — typically the hero image. ## When to Use - **`object-fit: cover`**: Card thumbnails, hero images, avatars — any fixed-dimension image container. - **`aspect-ratio`**: Whenever an image has a fixed container and you need to prevent layout shift. - **`srcset` + `sizes`**: Any image served in more than one viewport context. This is the standard for production images. - **``**: Art direction (different crops at different sizes) or format switching (AVIF/WebP with JPEG fallback). - **`loading="lazy"`**: All images below the fold. ## References - [Responsive Images — web.dev](https://web.dev/learn/design/responsive-images) - [Responsive Images in HTML — MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images) - [HTML Responsive Images Guide — CSS-Tricks](https://css-tricks.com/a-guide-to-the-responsive-images-syntax-in-html/) - [Responsive Images Best Practices in 2025 — DEV Community](https://dev.to/razbakov/responsive-images-best-practices-in-2025-4dlb) --- # currentColor Patterns > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/currentcolor-patterns ## The Problem AI agents tend to hard-code color values for every property — separate hex values for `color`, `border-color`, `box-shadow`, `outline`, and SVG `fill` — even when all of them should match the element's text color. This creates maintenance headaches: changing a component's color means updating multiple declarations. Worse, when parent components change text color (e.g., in hover states or theme switches), the borders, icons, and shadows don't follow along because they're pinned to a specific hex value. ## The Solution `currentColor` is a CSS keyword that resolves to the element's computed `color` value. It makes borders, shadows, outlines, SVG fills, and other color properties automatically follow the text color. This is one of the most under-used patterns in CSS — it has been supported since CSS3 and works in every browser. ### Key behavior - `currentColor` inherits through the cascade — if an element doesn't set its own `color`, it inherits from its parent - `border-color` defaults to `currentColor` implicitly, but other properties require it explicitly - It works with CSS custom properties and `color-mix()` ## Code Examples ### Borders That Follow Text Color ```css /* VERBOSE: separate color declarations */ .card { color: #1a365d; border: 1px solid #1a365d; } .card:hover { color: #2b6cb0; border-color: #2b6cb0; /* Must update separately */ } /* BETTER: currentColor keeps them in sync */ .card { color: #1a365d; border: 1px solid currentColor; } .card:hover { color: #2b6cb0; /* Border color updates automatically */ } ``` Indigo Component Border, icon, and shadow all use currentColor — they match the text automatically. Teal Component Same CSS — only the parent color changed. Everything else followed via currentColor. Red Component One color declaration controls text, border, icon stroke, and box shadow together. `} css={`.cc-demo { padding: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; font-family: system-ui, sans-serif; } .card { border: 2px solid currentColor; border-radius: 10px; padding: 1rem; box-shadow: 0 4px 12px color-mix(in srgb, currentColor 20%, transparent); background: white; } .card svg { margin-bottom: 0.5rem; } .card h4 { margin: 0 0 0.4rem; font-size: 1rem; } .card p { margin: 0; font-size: 0.85rem; line-height: 1.5; opacity: 0.8; }`} height={260} /> ### SVG Icons That Match Text Color This is where `currentColor` provides the most value. Inline SVGs with `fill="currentColor"` automatically adopt the text color of their parent: ```html Add to favorites ``` ```css .btn-primary { color: white; background: oklch(50% 0.22 264); } .btn-primary:hover { color: oklch(90% 0.05 264); /* SVG fill automatically updates to the new color */ } ``` ### Box Shadows ```css .tag { color: oklch(45% 0.2 264); border: 1px solid currentColor; box-shadow: 0 1px 3px currentColor; } /* Semi-transparent shadow using color-mix with currentColor */ .card { color: oklch(30% 0.05 264); box-shadow: 0 4px 12px color-mix(in oklch, currentColor 25%, transparent); } ``` ### Outlines and Focus Rings ```css /* Focus ring that matches the element's text color */ .input:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } /* Link focus ring that matches the link color */ a:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; } ``` ### Text Decorations ```css /* Underline color automatically matches text */ a { color: oklch(50% 0.2 264); text-decoration-color: currentColor; } /* Subtle underline using semi-transparent currentColor */ a { color: oklch(50% 0.2 264); text-decoration-color: color-mix(in oklch, currentColor 40%, transparent); } a:hover { text-decoration-color: currentColor; /* Full opacity on hover */ } ``` ### Multi-Colored Component Variants ```css .badge { color: var(--badge-color, oklch(45% 0.15 264)); border: 1px solid currentColor; background: color-mix(in oklch, currentColor 10%, transparent); } /* All color properties follow from one custom property */ .badge--success { --badge-color: oklch(45% 0.15 145); } .badge--warning { --badge-color: oklch(55% 0.18 85); } .badge--danger { --badge-color: oklch(50% 0.2 25); } ``` ```html Active Pending Failed ``` ### Dividers and Separators ```css .divider { border: none; border-block-start: 1px solid currentColor; opacity: 0.2; } /* The divider inherits the section's text color */ .dark-section { color: white; } .light-section { color: #333; } ``` ### Combining with CSS Custom Properties ```css :root { --link-color: oklch(50% 0.2 264); } a { color: var(--link-color); text-decoration-color: color-mix(in oklch, currentColor 50%, transparent); transition: color 0.2s; } a:hover { color: oklch(40% 0.25 264); /* text-decoration-color updates through currentColor */ } a svg { fill: currentColor; /* Icon follows link color */ } ``` ## Common AI Mistakes - Hard-coding the same color value in `color`, `border-color`, `box-shadow`, and SVG `fill` instead of using `currentColor` to keep them in sync - Not setting `fill="currentColor"` on inline SVG icons, then writing JavaScript to change SVG colors on state changes - Writing separate `:hover` rules to update border, shadow, and icon colors individually when `currentColor` would propagate the change from a single `color` update - Using `currentColor` where the element's `color` is inherited from a distant ancestor and might unexpectedly change — be intentional about which element sets the `color` - Forgetting that `border-color` already defaults to `currentColor` — explicitly setting `border-color: currentColor` is valid but redundant - Not leveraging `currentColor` with `color-mix()` for semi-transparent variants (e.g., `color-mix(in oklch, currentColor 25%, transparent)` for subtle shadows) - Using `currentColor` for `background-color` and making text invisible against its own background — `currentColor` is for accents, not backgrounds ## When to Use - **SVG icons in buttons and links**: The icon color automatically follows the text color through all states (default, hover, active, disabled, focus) - **Component borders**: A single `color` change updates the text and border together - **Focus indicators**: `outline: 2px solid currentColor` creates focus rings that always match the element's context - **Color-coordinated components**: Badges, tags, and alerts where border, background-tint, and text should derive from one color - **Text decorations**: Subtle underlines that match or partially match the text color ### When to avoid - **Backgrounds**: `background: currentColor` makes text invisible unless you set a different `color` on a child element - **Colors that should NOT change with the text**: Logos, brand marks, or icons that need a fixed color regardless of context - **Complex multi-color components**: When borders, icons, and text intentionally use different colors ## References - [MDN: currentColor](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/currentcolor) - [CSS-Tricks: currentColor](https://css-tricks.com/currentcolor/) - [currentColor and SVGs — Go Make Things](https://gomakethings.com/currentcolor-and-svgs/) - [Using the currentColor CSS Property with SVG — Echobind](https://echobind.com/post/currentcolor-css-property-with-svg) --- # Blend Modes > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/blend-modes ## The Problem AI agents rarely reach for CSS blend modes, even when the design clearly calls for them. Text over images gets a solid overlay `div` instead of a blend mode. Image tinting is done with `filter` when `background-blend-mode` would be simpler. When blend modes are used, AI often forgets the `isolation` property and accidentally blends elements with unrelated backgrounds further up the DOM. ## The Solution CSS provides two blend mode properties: - **`mix-blend-mode`** — Controls how an element's colors blend with the content directly behind it in the stacking order - **`background-blend-mode`** — Controls how an element's multiple background layers blend with each other Both accept the same set of blend mode values (e.g., `multiply`, `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `difference`, `exclusion`, `soft-light`). Use `isolation: isolate` on a container to prevent its children's blend modes from leaking into parent backgrounds. ## Code Examples ### Darkening Image Overlay The classic use case: readable text over a photograph. Instead of a semi-transparent black overlay, use `multiply` to darken the image while preserving contrast. ```css .hero-overlay { position: relative; background: url("hero.jpg") center / cover; } .hero-overlay::before { content: ""; position: absolute; inset: 0; background: hsl(220deg 60% 20%); mix-blend-mode: multiply; } .hero-content { position: relative; z-index: 1; color: white; } ``` ```html Hero Title Text is readable without a flat black overlay. ``` `multiply` darkens light areas while preserving the image's contrast and color variation, unlike a uniform `rgba(0,0,0,0.5)` overlay that flattens everything. ### Image Color Tinting with background-blend-mode ```css /* Duotone effect */ .duotone { background: url("photo.jpg") center / cover, linear-gradient(#1a1a2e, #3b82f6); background-blend-mode: luminosity; } /* Warm tint */ .warm-tint { background: url("photo.jpg") center / cover, hsl(30deg 80% 50%); background-blend-mode: overlay; } /* Cool monochrome */ .cool-mono { background: url("photo.jpg") center / cover, hsl(220deg 80% 30%); background-blend-mode: color; } ``` ### Text That Adapts to Background `mix-blend-mode: difference` on text inverts its color relative to the background, keeping it readable over both light and dark regions. ```css .adaptive-text { color: white; mix-blend-mode: difference; font-size: 3rem; font-weight: 700; } ``` ### Knockout Text Effect Using `screen` to create text that "cuts through" to reveal a background. ```css .knockout-container { background: url("texture.jpg") center / cover; isolation: isolate; } .knockout-text { background: black; color: white; mix-blend-mode: screen; font-size: 4rem; font-weight: 900; padding: 20px; } ``` `screen` makes the black areas transparent and white areas opaque, so white text reveals the texture while the black background vanishes. ### The `isolation` Property Without `isolation: isolate`, blend modes propagate up the DOM and blend with all content behind the element, including unrelated backgrounds. Setting `isolation: isolate` on a container creates a new stacking context that confines blending to its children. ```css /* Without isolation — text blends with page background too */ .card-broken { background: white; } .card-broken .blend-text { mix-blend-mode: multiply; /* Multiplies with everything behind, including page background */ } /* With isolation — blending stops at the card */ .card-fixed { background: white; isolation: isolate; } .card-fixed .blend-text { mix-blend-mode: multiply; /* Only multiplies with the card's white background */ } ``` ### Blended Background Patterns ```css /* Plaid pattern using blended gradients */ .plaid { background: repeating-linear-gradient( 0deg, hsl(220deg 80% 60% / 0.3) 0px, hsl(220deg 80% 60% / 0.3) 20px, transparent 20px, transparent 40px ), repeating-linear-gradient( 90deg, hsl(350deg 80% 60% / 0.3) 0px, hsl(350deg 80% 60% / 0.3) 20px, transparent 20px, transparent 40px ), hsl(0deg 0% 95%); background-blend-mode: multiply; } ``` ### Hover Effect with Blend Mode ```css .image-card { position: relative; overflow: hidden; } .image-card img { width: 100%; display: block; transition: filter 0.3s ease; } .image-card::after { content: ""; position: absolute; inset: 0; background: hsl(220deg 80% 50%); mix-blend-mode: soft-light; opacity: 0; transition: opacity 0.3s ease; } .image-card:hover::after { opacity: 1; } ``` ### Quick Reference: Common Blend Modes ```css /* Darken family — result is darker than both layers */ .darken { mix-blend-mode: darken; } .multiply { mix-blend-mode: multiply; } /* most useful */ .color-burn { mix-blend-mode: color-burn; } /* Lighten family — result is lighter than both layers */ .lighten { mix-blend-mode: lighten; } .screen { mix-blend-mode: screen; } /* most useful */ .color-dodge { mix-blend-mode: color-dodge; } /* Contrast family — darkens darks, lightens lights */ .overlay { mix-blend-mode: overlay; } /* most useful */ .soft-light { mix-blend-mode: soft-light; } /* subtle version */ .hard-light { mix-blend-mode: hard-light; } /* Difference family — inverts based on brightness */ .difference { mix-blend-mode: difference; } .exclusion { mix-blend-mode: exclusion; } /* softer version */ ``` ## Live Previews BLEND`} css={` .demo { width: 100%; height: 100%; font-family: system-ui, sans-serif; } .blend-container { width: 100%; height: 100%; background: linear-gradient(135deg, #3b82f6 0%, #ec4899 50%, #f59e0b 100%); display: flex; justify-content: center; align-items: center; isolation: isolate; } .blend-text { font-size: 80px; font-weight: 900; color: white; mix-blend-mode: difference; margin: 0; letter-spacing: 8px; } `} height={250} /> luminosity blendno blend (normal)`} css={` .demo { display: flex; gap: 16px; height: 100%; padding: 16px; background: #0f172a; font-family: system-ui, sans-serif; } .duotone, .no-blend { flex: 1; border-radius: 12px; background: linear-gradient(135deg, #ec4899, #8b5cf6, #3b82f6, #06b6d4), linear-gradient(45deg, #000 0%, #fff 100%); background-size: cover; display: flex; align-items: flex-end; padding: 12px; position: relative; } .duotone { background-blend-mode: luminosity; } .no-blend { background-blend-mode: normal; } .label { background: hsl(0deg 0% 0% / 0.5); color: white; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; } `} height={250} /> Without isolation — blend leaks to page backgroundWith isolation: isolate — blend contained to card`} css={` .demo { display: flex; gap: 20px; justify-content: center; align-items: center; height: 100%; background: repeating-linear-gradient( 45deg, #e2e8f0 0px, #e2e8f0 10px, #f1f5f9 10px, #f1f5f9 20px ); padding: 24px; font-family: system-ui, sans-serif; } .card { position: relative; width: 200px; background: white; border-radius: 12px; overflow: hidden; padding-bottom: 16px; } .with-isolate { isolation: isolate; } .overlay { height: 80px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); mix-blend-mode: multiply; } .card p { font-size: 12px; color: #334155; padding: 0 12px; line-height: 1.5; margin: 8px 0 0; } `} height={240} /> ## Common AI Mistakes - **Using `rgba(0,0,0,0.5)` overlays instead of blend modes** — A semi-transparent black overlay flattens the image uniformly. `multiply` preserves contrast and color variation. - **Forgetting `isolation: isolate`** — Without it, blend modes propagate up the DOM and blend with unrelated backgrounds, producing unexpected results. - **Confusing `mix-blend-mode` and `background-blend-mode`** — `mix-blend-mode` blends the element with what is behind it. `background-blend-mode` blends the element's own background layers with each other. - **Using blend modes on invisible elements** — Setting `mix-blend-mode` on an element with `opacity: 0` or `display: none` has no effect and is wasted code. - **Not accounting for text readability** — Blend modes like `difference` or `exclusion` produce unpredictable text contrast. Always test over both light and dark regions of the background. - **Applying blend modes to interactive elements** — Blend modes can make button and link colors unpredictable over varying backgrounds, harming accessibility. ## When to Use - **Image overlays** — Darken or tint hero images for readable text without flattening contrast - **Duotone and color effects** — `background-blend-mode` with a color and `luminosity` or `color` mode for Instagram-like image treatments - **Knockout text** — Reveal a background texture through text shapes using `screen` - **Adaptive text** — `difference` for text that stays readable over both light and dark image regions - **Decorative backgrounds** — Blend multiple gradient layers for rich patterns without images ## References - [mix-blend-mode — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) - [background-blend-mode — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/background-blend-mode) - [Blending Modes in CSS — Ahmad Shadeed](https://ishadeed.com/article/blending-modes-css/) - [Blend Modes — web.dev](https://web.dev/learn/css/blend-modes) - [Creative Text Styling with mix-blend-mode — LogRocket](https://blog.logrocket.com/creative-text-styling-with-the-css-mix-blend-mode-property/) --- # Smooth Shadow Transitions > Source: https://takazudomodular.com/pj/zcss/docs/styling/shadows-and-borders/smooth-shadow-transitions ## The Problem Hover effects on cards commonly involve changing `box-shadow` to make the element appear to lift. AI agents naively write `transition: box-shadow 0.3s ease`, which works visually but triggers expensive repaints on every animation frame. The browser must recalculate and repaint the shadow pixels each frame because `box-shadow` is not a compositor-friendly property. On pages with many interactive cards, this causes visible frame drops and jank, especially on lower-powered devices. ## The Solution Instead of transitioning `box-shadow` directly, place the heavier shadow on a pseudo-element (`::after`) and transition only its `opacity`. Since `opacity` is handled entirely by the GPU compositor without triggering layout or paint, the animation runs at a smooth 60 FPS regardless of shadow complexity. For cases where you need to animate individual shadow parameters (blur, spread, color) independently, the `@property` rule lets the browser treat custom properties as typed, animatable values. ## Code Examples ### The Naive (Expensive) Approach This works but performs poorly because `box-shadow` changes trigger paint on every frame. ```css /* Avoid this for performance-critical animations */ .card-naive { box-shadow: 0 1px 2px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s ease; } .card-naive:hover { box-shadow: 0 4px 8px hsl(0deg 0% 0% / 0.1), 0 16px 32px hsl(0deg 0% 0% / 0.08); } ``` ### The Performant Approach: Pseudo-Element Opacity ```css .card { position: relative; border-radius: 12px; background: white; /* Base shadow — always visible */ box-shadow: 0 1px 2px hsl(0deg 0% 0% / 0.1); } .card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; /* Hover shadow — pre-rendered but invisible */ box-shadow: 0 4px 8px hsl(220deg 60% 50% / 0.08), 0 12px 24px hsl(220deg 60% 50% / 0.06), 0 24px 48px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; /* Keep pseudo-element behind content */ z-index: -1; } .card:hover::after { opacity: 1; } ``` ```html Performant Shadow Hover Shadow transitions via opacity, not box-shadow. ``` The browser renders both shadows once (at their full values), then simply fades the pseudo-element's opacity on hover. No repaint needed — just compositing. ### Complete Card with Lift Effect Combine the pseudo-element shadow with a subtle `transform: translateY()` for a convincing "lift off the page" hover. ```css .lift-card { position: relative; border-radius: 12px; background: white; box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.06), 0 2px 4px hsl(220deg 60% 50% / 0.06); transition: transform 0.3s ease; } .lift-card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 2px 4px hsl(220deg 60% 50% / 0.05), 0 8px 16px hsl(220deg 60% 50% / 0.05), 0 16px 32px hsl(220deg 60% 50% / 0.05), 0 32px 64px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; z-index: -1; } .lift-card:hover { transform: translateY(-4px); } .lift-card:hover::after { opacity: 1; } ``` Both `transform` and `opacity` are compositor-friendly, so this entire hover effect runs without layout or paint. ### The @property Trick for Individual Shadow Parameters When you need granular control — for example, animating only the shadow's blur or color independently — use `@property` to create typed custom properties that the browser can interpolate. ```css @property --shadow-blur { syntax: ""; inherits: false; initial-value: 2px; } @property --shadow-y { syntax: ""; inherits: false; initial-value: 1px; } @property --shadow-color { syntax: ""; inherits: false; initial-value: hsl(220deg 60% 50% / 0.1); } .card-property { box-shadow: 0 var(--shadow-y) var(--shadow-blur) var(--shadow-color); transition: --shadow-blur 0.3s ease, --shadow-y 0.3s ease, --shadow-color 0.5s ease; } .card-property:hover { --shadow-blur: 24px; --shadow-y: 12px; --shadow-color: hsl(220deg 60% 50% / 0.2); } ``` This transitions each shadow parameter on its own timing. The browser still repaints per frame (like the naive approach), but you gain precise control over individual parameters. Use this when creative control outweighs performance concerns, such as a single hero element. ### Comparing All Three Approaches ```css /* 1. Naive — simple but expensive */ .approach-naive { box-shadow: 0 2px 4px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s; } /* 2. Pseudo-element opacity — performant */ .approach-pseudo { position: relative; box-shadow: 0 2px 4px hsl(0deg 0% 0% / 0.1); } .approach-pseudo::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 12px 24px hsl(0deg 0% 0% / 0.15); opacity: 0; transition: opacity 0.3s; z-index: -1; } /* 3. @property — creative control, moderate performance */ @property --blur { syntax: ""; inherits: false; initial-value: 4px; } .approach-property { box-shadow: 0 2px var(--blur) hsl(0deg 0% 0% / 0.1); transition: --blur 0.3s; } .approach-property:hover { --blur: 24px; } ``` ## Live Previews Naive ApproachTransitions box-shadow directly (triggers repaint every frame)Hover to see effectPerformant ApproachTransitions pseudo-element opacity (GPU compositing only)Hover to see effect`} css={` .demo { display: flex; gap: 24px; justify-content: center; align-items: center; height: 100%; background: #f1f5f9; padding: 24px; font-family: system-ui, sans-serif; } .card { padding: 24px; border-radius: 12px; background: white; width: 220px; cursor: pointer; } .card h3 { font-size: 15px; font-weight: 600; margin: 0 0 8px; color: #0f172a; } .card p { font-size: 13px; color: #64748b; margin: 0 0 12px; line-height: 1.5; } .hint { font-size: 12px; color: #3b82f6; font-weight: 500; } .naive { box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s ease; } .naive:hover { box-shadow: 0 4px 8px hsl(0deg 0% 0% / 0.1), 0 16px 32px hsl(0deg 0% 0% / 0.08); } .performant { position: relative; box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.1); transition: transform 0.3s ease; } .performant::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 4px 8px hsl(220deg 60% 50% / 0.08), 0 12px 24px hsl(220deg 60% 50% / 0.06), 0 24px 48px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; z-index: -1; } .performant:hover { transform: translateY(-2px); } .performant:hover::after { opacity: 1; } `} height={280} /> ## Common AI Mistakes - **Always using `transition: box-shadow`** — This is the most common mistake. It works visually but causes jank on pages with many hoverable cards because the browser repaints the shadow every frame. - **Forgetting `position: relative` on the parent** — The pseudo-element needs a positioned parent to anchor itself. Without it, the shadow renders in unexpected positions. - **Missing `border-radius: inherit`** — The pseudo-element does not inherit border-radius by default. Without it, the hover shadow has square corners while the card has round ones. - **Forgetting `z-index: -1` on the pseudo-element** — Without negative z-index, the pseudo-element's shadow sits on top of the card content, blocking text selection and click events. - **Using `will-change: box-shadow`** — This does not help. `will-change` promotes an element to its own compositor layer, but `box-shadow` changes still require repaints within that layer. The pseudo-element opacity trick is the correct solution. - **Not using `@property` for typed custom properties** — Standard `--custom-properties` are treated as strings and cannot be interpolated. `@property` with explicit `syntax` is required for animated custom properties. - **Overly complex shadows in frequently animated elements** — Even with the opacity trick, rendering very complex shadows (6+ layers) on many elements simultaneously can impact initial paint performance. ## When to Use - **Card hover effects** — The pseudo-element opacity trick is the standard approach for hoverable card grids - **Interactive lists and tables** — Row hover effects that need shadow changes without performance cost - **Hero elements with creative shadows** — The `@property` trick for single elements where precise parameter animation matters - **Any element with `transition: box-shadow`** — Replace with the pseudo-element technique whenever you see it in performance-sensitive contexts ## References - [How to Animate Box-Shadow with Silky Smooth Performance — Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/) - [Box-Shadow Transition Performance — Cloud 66](https://blog.cloud66.com/box-shadow-transition-performance) - [Exploring @property and Its Animating Powers — CSS-Tricks](https://css-tricks.com/exploring-property-and-its-animating-powers/) - [The Times You Need a Custom @property Instead of a CSS Variable — Smashing Magazine](https://www.smashingmagazine.com/2024/05/times-need-custom-property-instead-css-variable/) - [box-shadow — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) --- # Screen-Width Based Font Size Definition > Source: https://takazudomodular.com/pj/zcss/docs/typography/font-sizing/screen-width-based-font-size ## The Problem Basic `clamp()` typography scales font size linearly across the **entire** viewport range. This works for simple cases, but falls short when you need precise control over how text scales within specific breakpoint ranges. For example, a site logo might need to: - Stay at 15–24px within the `lg` breakpoint range (1024–1280px) - Scale to 24–30px within the `xl` range (1280–1536px) - Reach 30–36px in the `2xl` range (1536–1920px) A single `clamp()` cannot express this piecewise scaling. You would need media queries with fixed sizes, creating jarring jumps — or a single clamp that doesn't precisely hit the sizes you want at each breakpoint boundary. ## The Solution Combine **breakpoint-specific media queries** with **per-range `clamp()` values**. Each breakpoint gets its own `clamp()` that smoothly scales within that range, and the ranges are stitched together so the maximum of one range equals the minimum of the next. This article builds on [Fluid Font Sizing with clamp()](../fluid-font-sizing). Make sure you understand basic `clamp()` usage before reading further. ### The Formula For a given breakpoint range from `startVw` to `endVw`, scaling font size from `minSize` to `maxSize`: ``` slope = (maxSize - minSize) / (endVw - startVw) intercept = minSize - slope × startVw ``` This gives you: ```css font-size: clamp(minSize, calc(intercept + slope × 1vw), maxSize); ``` For example, scaling 15px → 24px across the 1024px → 1280px range: ``` slope = (24 - 15) / (1280 - 1024) = 9 / 256 ≈ 0.03516 (3.516vw) intercept = 15 - 0.03516 × 1024 = 15 - 36 ≈ -21px ``` Result: `clamp(15px, calc(-21px + 3.516vw), 24px)` **Verification:** At 1024px → `calc(-21 + 0.03516 × 1024)` = `calc(-21 + 36)` = 15px. At 1280px → `calc(-21 + 0.03516 × 1280)` = `calc(-21 + 45)` = 24px. ## Code Examples ### Basic Segmented Fluid Font Size ```css .site-title { /* Base: fixed size for small screens */ font-size: 15px; } /* lg: 1024px – 1280px → scale 15px to 24px */ @media (min-width: 1024px) { .site-title { font-size: clamp(15px, calc(-21px + 3.516vw), 24px); } } /* xl: 1280px – 1536px → scale 24px to 30px */ @media (min-width: 1280px) { .site-title { font-size: clamp(24px, calc(-6px + 2.344vw), 30px); } } /* 2xl: 1536px – 1920px → scale 30px to 36px */ @media (min-width: 1536px) { .site-title { font-size: clamp(30px, calc(6px + 1.563vw), 36px); } } ``` Note: The demos below use scaled-down breakpoints (320px / 500px / 768px) so the fluid scaling is visible within the preview iframe. The code examples above show production-appropriate breakpoints. Site Title (segmented clamp) Takazudo Modular This title uses different clamp() values at each breakpoint range, giving precise control over scaling behavior. Base (<320px) 14px fixed 320–500px 14px → 20px 500–768px 20px → 28px 768px+ 28px → 36px `} css={`.seg-demo { padding: 2rem; font-family: system-ui, sans-serif; } .seg-demo__label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(0 0% 40%); margin-bottom: 0.25rem; } .seg-demo__title { font-size: 14px; font-weight: 700; line-height: 1.2; margin: 0 0 1rem; color: hsl(220 30% 15%); } @media (min-width: 320px) { .seg-demo__title { font-size: clamp(14px, calc(3.3px + 3.333vw), 20px); } } @media (min-width: 500px) { .seg-demo__title { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } @media (min-width: 768px) { .seg-demo__title { font-size: clamp(28px, calc(4px + 3.125vw), 36px); } } .seg-demo__info { margin-bottom: 1.5rem; } .seg-demo__info p { font-size: 0.875rem; line-height: 1.6; color: hsl(0 0% 35%); margin: 0; } .seg-demo__info code { background: hsl(220 15% 94%); padding: 0.15em 0.35em; border-radius: 3px; font-size: 0.85em; } .seg-demo__comparison { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 0.75rem; } .seg-demo__card { background: hsl(220 15% 96%); border-radius: 6px; padding: 0.75rem 1rem; } .seg-demo__card-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; color: hsl(0 0% 40%); margin-bottom: 0.25rem; } .seg-demo__card-value { font-size: 0.9rem; font-weight: 600; color: hsl(220 50% 40%); }`} height={320} /> ### Design Token System with Segmented Scaling Define a reusable token system where each font size step has precise per-breakpoint scaling: ```css :root { /* Small screens: fixed base sizes */ --font-size-display: 24px; --font-size-title: 20px; --font-size-heading: 18px; --font-size-body: 16px; } /* lg: 1024px – 1280px */ @media (min-width: 1024px) { :root { --font-size-display: clamp(28px, calc(-4px + 3.125vw), 36px); --font-size-title: clamp(22px, calc(-2px + 2.344vw), 28px); --font-size-heading: clamp(18px, calc(2px + 1.563vw), 22px); --font-size-body: clamp(16px, calc(8px + 0.781vw), 18px); } } /* xl: 1280px – 1536px */ @media (min-width: 1280px) { :root { --font-size-display: clamp(36px, calc(-24px + 4.688vw), 48px); --font-size-title: clamp(28px, calc(-12px + 3.125vw), 36px); --font-size-heading: clamp(22px, calc(2px + 1.563vw), 26px); --font-size-body: clamp(18px, calc(8px + 0.781vw), 20px); } } ``` --font-size-display Display Text --font-size-title Title Text --font-size-heading Heading Text --font-size-body Body text that uses the base font size token for comfortable reading across all viewport sizes. `} css={`:root { --font-size-display: 24px; --font-size-title: 20px; --font-size-heading: 18px; --font-size-body: 16px; } @media (min-width: 320px) { :root { --font-size-display: clamp(24px, calc(10.7px + 4.167vw), 32px); --font-size-title: clamp(20px, calc(11.1px + 2.778vw), 26px); --font-size-heading: clamp(18px, calc(12.7px + 1.667vw), 22px); --font-size-body: clamp(16px, calc(13.3px + 0.833vw), 18px); } } @media (min-width: 500px) { :root { --font-size-display: clamp(32px, calc(-0.9px + 6.343vw), 50px); --font-size-title: clamp(26px, calc(3.8px + 4.478vw), 38px); --font-size-heading: clamp(22px, calc(7.1px + 2.985vw), 30px); --font-size-body: clamp(18px, calc(11.3px + 1.493vw), 22px); } } .token-demo { padding: 2rem; font-family: system-ui, sans-serif; } .token-demo__scale { display: flex; flex-direction: column; gap: 1.25rem; } .token-demo__item { display: flex; flex-direction: column; gap: 0.25rem; padding-bottom: 1.25rem; border-bottom: 1px solid hsl(0 0% 90%); } .token-demo__item:last-child { border-bottom: none; padding-bottom: 0; } .token-demo__label { font-size: 0.75rem; font-family: monospace; color: hsl(220 50% 50%); letter-spacing: 0.02em; } .token-demo__text { color: hsl(220 30% 15%); font-weight: 600; line-height: 1.3; } .token-demo__text--display { font-size: var(--font-size-display); } .token-demo__text--title { font-size: var(--font-size-title); } .token-demo__text--heading { font-size: var(--font-size-heading); } .token-demo__text--body { font-size: var(--font-size-body); font-weight: 400; line-height: 1.6; }`} height={400} /> ### Calculating clamp() Values for Any Range Here is a step-by-step example for calculating the `clamp()` preferred value for any viewport range. **Goal:** Scale font from 22px at 1024px viewport to 28px at 1280px viewport. ``` Step 1: Calculate the slope slope = (28 - 22) / (1280 - 1024) = 6 / 256 ≈ 0.02344 Step 2: Calculate the intercept (base offset in px) intercept = minSize - slope × startVw = 22 - 0.02344 × 1024 ≈ 22 - 24 = -2px Step 3: Assemble the clamp() font-size: clamp(22px, calc(-2px + 2.344vw), 28px); ``` **Verification:** At 1024px viewport → `calc(-2 + 0.02344 × 1024)` = `calc(-2 + 24)` = 22px. At 1280px → `calc(-2 + 0.02344 × 1280)` = `calc(-2 + 30)` = 28px. ### Full Page Header Example A real-world example showing a site header with logo text that scales smoothly across all breakpoints: Project Dashboard Docs Blog About Welcome The header logo text above scales smoothly within each breakpoint range using segmented clamp() values. Try switching between Mobile and Full viewports to see the difference. `} css={`.header-demo__inner { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; background: hsl(220 25% 18%); font-family: system-ui, sans-serif; } .header-demo__logo { display: flex; align-items: center; gap: 0.5rem; } .header-demo__icon { width: 1.5em; height: 1.5em; color: hsl(35 90% 55%); flex-shrink: 0; } .header-demo__title { font-size: 14px; font-weight: 700; color: hsl(0 0% 100%); white-space: nowrap; } @media (min-width: 320px) { .header-demo__title { font-size: clamp(14px, calc(3.3px + 3.333vw), 20px); } } @media (min-width: 500px) { .header-demo__title { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } @media (min-width: 768px) { .header-demo__title { font-size: clamp(28px, calc(4px + 3.125vw), 36px); } } .header-demo__nav { display: flex; gap: 1rem; } .header-demo__link { font-size: 0.875rem; color: hsl(0 0% 75%); text-decoration: none; } .header-demo__link:hover { color: hsl(0 0% 100%); } .header-demo__main { padding: 2rem 1.5rem; font-family: system-ui, sans-serif; } .header-demo__heading { font-size: 20px; font-weight: 700; color: hsl(220 30% 15%); margin: 0 0 0.75rem; } @media (min-width: 500px) { .header-demo__heading { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } .header-demo__body { font-size: 1rem; line-height: 1.6; color: hsl(0 0% 35%); margin: 0; max-width: 60ch; }`} height={280} /> ## Common AI Mistakes - Using a single `clamp()` across the full viewport range when precise per-breakpoint control is needed — the scaling rate cannot be tuned for different ranges - Forgetting to align the `max` of one range with the `min` of the next, causing visible jumps at breakpoint boundaries (e.g., 24px max at lg but 26px min at xl creates a 2px jump) - Getting the formula wrong — the denominator is the **viewport range** `(endVw - startVw)`, not `(endVw - minSize)`. Mixing viewport width with font size produces incorrect slopes - Using `vw` alone without `calc()` offset — `font-size: 2vw` gives 20px at 1000px viewport but you cannot control where it starts and stops - Applying segmented clamp to body text or small UI labels where a single `clamp()` or fixed size is sufficient — this technique is best for display and title text - Not verifying the math — always check that `calc(intercept + slope × startVw)` equals your intended minimum size ## When to Use - **Site logos and brand text** that must hit exact sizes at specific breakpoints - **Design token font-size definitions** where each token step needs independent scaling behavior - **Display headings** in hero sections that require different scaling rates at different viewport ranges - **Any element** where a single `clamp()` doesn't give enough control over the scaling curve Prefer a single `clamp()` (covered in [Fluid Font Sizing](../fluid-font-sizing)) when: - The scaling range is simple (one min → one max across all viewports) - Pixel-precise control at breakpoint boundaries is not required - You want minimal CSS for straightforward responsive text ## References - [MDN: clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Linearly Scale font-size with CSS clamp() — CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Modern Fluid Typography Using CSS Clamp — Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Fluid Type Scale Calculator](https://www.fluid-type-scale.com/) --- # Japanese Font Family Specification > Source: https://takazudomodular.com/pj/zcss/docs/typography/fonts/japanese-font-family ## The Problem Specifying `font-family` for Japanese websites is significantly more complex than for Latin-script sites. Simply writing `sans-serif` produces poor results — browsers fall back to system defaults that vary widely by OS and may render Japanese characters as ugly bitmapped fonts on older systems. There are several compounding challenges: 1. **Platform fragmentation**: macOS ships with Hiragino fonts, Windows 10+ uses Yu Gothic, and Linux/Android relies on Noto Sans CJK. Each platform requires explicit font names to get good results. 2. **Japanese fonts contain Latin glyphs**: Every Japanese font includes its own rendition of ASCII characters. If the Japanese font is listed first, all Latin text (English, numbers, symbols) gets rendered in the Japanese font's Latin variant — typically wider and less refined than dedicated Latin typefaces. 3. **Yu Gothic weight rendering bug**: Yu Gothic on Windows renders with a hairline-thin stroke weight at `font-weight: normal` due to a Windows font mapping quirk. Without a workaround, Japanese text appears unnaturally light. 4. **Web font size**: A Japanese character set contains thousands of glyphs. Noto Sans JP weighs ~9MB uncompressed (TTF); even as WOFF2, a full character set is several MB — far too large for practical web font usage without subsetting or a variable font strategy. ## The Solution The modern approach uses a carefully ordered system font stack. The ordering principle is: 1. Latin web font(s) first — so English text uses the intended typeface 2. Japanese system fonts, platform by platform 3. `sans-serif` as the final catch-all ### Platform Font Reference | Platform | Recommended Font | Notes | |---|---|---| | macOS / iOS | `"Hiragino Sans"` | Default since macOS 10.11 El Capitan; 10 weight variants | | macOS (compatibility) | `"Hiragino Kaku Gothic ProN"` | Older but widely supported; only 2 weights | | Windows 10+ | `"Yu Gothic UI"` | UI variant with correct weight mapping | | Windows 10 (fallback) | `"Yu Gothic"` | Original; renders thin at default weight | | Windows Vista–8.1 | `"Meiryo"` | Legacy; no longer the default, include for old OS support | | Linux / Android | `"Noto Sans CJK JP"` | Open source; packaged by most Linux distros | | Android (Google Fonts) | `"Noto Sans JP"` | Subset version used on Android and via Google Fonts | ### The Recommended Stack ```css body { font-family: /* 1. Latin web font (if loaded via @font-face or Google Fonts) */ 'Inter', /* 2. macOS / iOS — modern Hiragino (10 weights) */ 'Hiragino Sans', /* 3. Windows 10+ — "Yu Gothic UI" fixes the weight rendering bug */ 'Yu Gothic UI', 'Yu Gothic', /* 4. Older macOS / iOS compatibility */ 'Hiragino Kaku Gothic ProN', /* 5. Linux / Android */ 'Noto Sans CJK JP', 'Noto Sans JP', /* 6. Final fallback */ sans-serif; } ``` ### Without a Latin Web Font If you are not loading any Latin web fonts, drop step 1. The browser will use the Latin glyphs built into whichever Japanese font resolves first, which is acceptable but not ideal for branded typography: ```css body { font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } ``` ### The Yu Gothic Weight Fix `"Yu Gothic UI"` is a UI-optimized variant of Yu Gothic included in Windows 10 and later. It maps font-weight values correctly, so `font-weight: 400` renders at a readable stroke weight. The original `"Yu Gothic"` maps all weights below 700 to the lightest available variant, making body text appear hairline-thin. Always list `"Yu Gothic UI"` before `"Yu Gothic"` in your stack: ```css /* Wrong — Yu Gothic renders thin at 400 on Windows */ font-family: 'Hiragino Sans', 'Yu Gothic', sans-serif; /* Correct — Yu Gothic UI has proper weight mapping */ font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', sans-serif; ``` ## Code Examples ### Recommended Stack in Action font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', sans-serif 日本語のWebサイトにおけるフォント指定は、OSによって使用できるフォントが異なるため、複数のフォントをフォールバックとして指定する必要があります。 Mixed text: CSS font-family for Japanese (日本語) websites in 2025. Latin only: The quick brown fox jumps over the lazy dog. 0123456789. `} css={`.font-demo { padding: 1.5rem; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; line-height: 1.8; } .font-demo__label { font-size: 0.7rem; color: hsl(220, 10%, 55%); background: hsl(220, 15%, 95%); border-radius: 4px; padding: 0.5rem 0.75rem; margin: 0 0 1rem; font-family: monospace; line-height: 1.4; } .font-demo__ja { font-size: 1rem; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .font-demo__mix { font-size: 1rem; color: hsl(220, 20%, 25%); margin: 0 0 0.75rem; } .font-demo__en { font-size: 1rem; color: hsl(220, 20%, 35%); margin: 0; }`} /> ### Latin Font Ordering: Before vs After Japanese Fonts Japanese fonts ship with their own Latin glyphs. Listing a Japanese font before your Latin font causes all ASCII characters to be rendered in the Japanese font's Latin variant — typically wider, with different spacing and stroke contrast. Always list Latin fonts first. Wrong order Hiragino Sans, system-ui, sans-serif CSS font-family 2025 ABCDEFabcdef 0123456789 日本語テキスト Latin characters rendered in Japanese font's Latin variant — wider proportions, different stroke contrast Correct order system-ui, Hiragino Sans, sans-serif CSS font-family 2025 ABCDEFabcdef 0123456789 日本語テキスト Latin characters use system-ui (San Francisco / Segoe UI), Japanese uses Hiragino `} css={`.ordering-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1.25rem; } .ordering-demo__col { border-radius: 8px; padding: 1rem; } .ordering-demo__col--wrong { background: hsl(0, 15%, 96%); border: 1px solid hsl(0, 25%, 88%); } .ordering-demo__col--correct { background: hsl(140, 15%, 95%); border: 1px solid hsl(140, 25%, 82%); } .ordering-demo__badge { display: inline-block; font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.5rem; border-radius: 3px; margin-bottom: 0.5rem; font-family: system-ui, sans-serif; } .ordering-demo__badge--wrong { background: hsl(0, 70%, 90%); color: hsl(0, 60%, 35%); } .ordering-demo__badge--correct { background: hsl(140, 55%, 85%); color: hsl(140, 50%, 28%); } .ordering-demo__stack-label { font-size: 0.65rem; font-family: monospace; color: hsl(220, 10%, 50%); margin: 0 0 0.75rem; line-height: 1.4; } .ordering-demo__text { font-size: 1.1rem; line-height: 1.9; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .ordering-demo__text--wrong-order { font-family: 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', system-ui, sans-serif; } .ordering-demo__text--correct-order { font-family: system-ui, -apple-system, 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', sans-serif; } .ordering-demo__note { font-size: 0.72rem; color: hsl(220, 15%, 50%); line-height: 1.5; margin: 0; font-family: system-ui, sans-serif; }`} /> ### Font Weight Variations with Japanese Text Japanese fonts support the same `font-weight` scale as Latin fonts, but behavior varies by OS font and weight availability. Hiragino Sans offers 10 weights (W0–W9), while Hiragino Kaku Gothic ProN only has 2 (W3, W6). 300 フォントウェイト Light — The quick brown fox jumps over the lazy dog 400 フォントウェイト Regular — The quick brown fox jumps over the lazy dog 500 フォントウェイト Medium — The quick brown fox jumps over the lazy dog 600 フォントウェイト SemiBold — The quick brown fox jumps over the lazy dog 700 フォントウェイト Bold — The quick brown fox jumps over the lazy dog 900 フォントウェイト Black — The quick brown fox jumps over the lazy dog `} css={`.weight-demo { padding: 1rem 1.5rem; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .weight-demo__row { display: flex; align-items: baseline; gap: 1rem; border-bottom: 1px solid hsl(220, 15%, 92%); padding: 0.4rem 0; } .weight-demo__row:last-child { border-bottom: none; } .weight-demo__label { font-size: 0.7rem; font-family: monospace; color: hsl(220, 15%, 55%); min-width: 2.5rem; flex-shrink: 0; } .weight-demo__text { font-size: 0.95rem; line-height: 1.5; color: hsl(220, 20%, 15%); margin: 0; }`} /> ### System Stack vs Minimal Fallback This demo shows the difference between a comprehensive system stack and just relying on `sans-serif`. On most modern desktop browsers the result may look similar, but on Linux systems without good CJK font configuration, `sans-serif` can fall back to bitmap or poorly-shaped fonts. Recommended stack 日本語のWebサイトでは、各OSに対応したフォントを明示的に指定することが重要です。 春はあけぼの。やうやう白くなりゆく山際、少し明かりて、紫だちたる雲の細くたなびきたる。 sans-serif only 日本語のWebサイトでは、各OSに対応したフォントを明示的に指定することが重要です。 春はあけぼの。やうやう白くなりゆく山際、少し明かりて、紫だちたる雲の細くたなびきたる。 `} css={`.stack-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: hsl(220, 15%, 88%); } .stack-compare__col { background: hsl(0, 0%, 100%); padding: 1.25rem; } .stack-compare__header { font-size: 0.7rem; font-weight: 700; font-family: monospace; color: hsl(220, 30%, 50%); margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid hsl(220, 15%, 90%); } .stack-compare__text { font-size: 1rem; line-height: 1.8; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .stack-compare__text--full { font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .stack-compare__text--minimal { font-family: sans-serif; } .stack-compare__subtext { font-size: 0.85rem; line-height: 1.8; color: hsl(220, 15%, 45%); margin: 0; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .stack-compare__subtext--minimal { font-family: sans-serif; }`} /> ## Common AI Mistakes - **Omitting Japanese system fonts entirely** — using only `system-ui, sans-serif` without any Japanese font names, which leaves font selection entirely to the browser's default mapping and produces inconsistent results across platforms - **Listing Japanese fonts before Latin fonts** — causing English text, numbers, and punctuation to be rendered in the Japanese font's Latin glyph set rather than the intended Latin typeface - **Using `"Yu Gothic"` without `"Yu Gothic UI"`** — resulting in hairline-thin text on Windows 10 at normal weight; `"Yu Gothic UI"` must come first in the stack - **Including `"Meiryo"` as the primary Windows font** — Meiryo was the default on Windows Vista and 7, but Yu Gothic has been the default since Windows 10; Meiryo should appear after Yu Gothic in a compatibility fallback position - **Loading Japanese web fonts without subsetting** — downloading full Noto Sans JP or similar (~9MB+) for every page, when system fonts would render just as well with zero download cost - **Using `italic` style on Japanese text** — Japanese fonts have no true italic variant; browsers apply a CSS oblique transform that makes characters look slanted and unnatural - **Assuming Hiragino Kaku Gothic ProN is the modern macOS font** — `"Hiragino Sans"` (added in macOS 10.11) is the modern replacement with 10 weight variants vs ProN's 2; always list Hiragino Sans first ## When to Use **Use the full system font stack** for any Japanese-language website or web application where: - The page contains Japanese text (body copy, headings, UI labels) - You want consistent rendering across macOS, Windows, and Linux - You are not loading a Japanese web font (the common case for performance) **Include a Latin web font first** when: - Your brand uses a specific Latin typeface (Inter, Noto Sans, Roboto, etc.) - Consistent Latin rendering across platforms is important - The web font is already being loaded for other reasons (variable fonts, etc.) **Load a Japanese web font** (Noto Sans JP, BIZ UDPGothic, etc.) only when: - Exact cross-platform visual consistency is required (print-quality design systems) - You are using font subsetting or progressive loading strategies to control file size - The design calls for a specific glyph style not available in system fonts ## References - [Best Japanese CSS font-family in 2025 — Bloomstreet Japan](https://www.bloomstreetjapan.com/best-japanese-font-setting-for-websites/) - [Japanese web safe fonts and how to use them — JStockMedia](https://jstockmedia.com/blog/japanese-web-safe-fonts-and-how-to-use-them-in-web-design/) - [system-fonts/modern-font-stacks — GitHub](https://github.com/system-fonts/modern-font-stacks) - [Japanese typography on the web — Pavel Laptev / Medium](https://pavellaptev.medium.com/japanese-typography-on-the-web-tips-and-tricks-981f120ad20e) - [MDN: font-family](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) - [MDN: font-variant-east-asian](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-east-asian) --- # Text Wrap: balance and pretty > Source: https://takazudomodular.com/pj/zcss/docs/typography/text-control/text-wrap-balance-pretty ## The Problem Headings and short text blocks often end up with awkward last lines — a single orphaned word dangling on its own line, creating an uneven visual shape. Developers traditionally worked around this with manual ` ` characters, explicit `` tags, or JavaScript-based solutions that measure text width and reflow content. These approaches are brittle: they break at different viewport sizes, different font sizes, and in different languages. Now CSS handles this natively with the `text-wrap` property. ## The Solution The `text-wrap` property offers three values beyond the default `wrap` that give fine-grained control over how text breaks across lines: - **`balance`** — Distributes text evenly across all lines so no single line is significantly longer or shorter than others. Ideal for headings, labels, and short text blocks. - **`pretty`** — Prevents orphaned words on the last line of a paragraph. The browser adjusts earlier line breaks to ensure the final line has at least two words. Designed for body text. - **`stable`** — Ensures lines that have already been laid out don't re-wrap when editable content changes. Useful for `contenteditable` areas and live editing interfaces. ### Core Principles #### Use `balance` for Headings and Short Text `text-wrap: balance` works by making all lines roughly equal in width. The browser calculates the optimal line breaks so that the shortest line is as long as possible. This creates a visually centered, even block of text — perfect for headings, pull quotes, and captions. However, browsers limit balancing to approximately 6 lines for performance reasons. Beyond that threshold, the text falls back to normal wrapping. #### Use `pretty` for Body Text `text-wrap: pretty` focuses specifically on the last line. It prevents a single orphaned word from appearing alone on the final line by adjusting earlier line breaks. Unlike `balance`, it doesn't try to equalize all lines — it only ensures the ending looks clean. #### Use `stable` for Editable Content `text-wrap: stable` prevents previously laid-out lines from shifting when new content is typed after them. This avoids the jarring re-wrap effect in `contenteditable` elements and text editors. ## Code Examples ### Basic Usage ```css h1, h2, h3 { text-wrap: balance; } p { text-wrap: pretty; } ``` ### Production Typography System ```css /* Balance all heading levels */ h1, h2, h3, h4, h5, h6 { text-wrap: balance; } /* Prevent orphans in body text */ p, li, blockquote { text-wrap: pretty; } /* Stable wrapping for editable areas */ [contenteditable] { text-wrap: stable; } ``` Default wrapping Designing Accessible User Interfaces for Modern Web Applications text-wrap: balance Designing Accessible User Interfaces for Modern Web Applications `} css={`.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .column { background: hsl(220 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(220 60% 50%); background: hsl(220 60% 94%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .heading-default { font-size: 1.35rem; line-height: 1.3; color: hsl(220 20% 20%); margin: 0; text-wrap: wrap; } .heading-balanced { font-size: 1.35rem; line-height: 1.3; color: hsl(220 20% 20%); margin: 0; text-wrap: balance; }`} /> Default wrapping Performance optimization requires a careful balance between load time and interactivity. Users expect pages to render within two seconds, and every additional resource increases the risk of abandonment. Prioritize critical rendering paths and defer non-essential assets to improve the perceived speed of your application and keep users engaged. text-wrap: pretty Performance optimization requires a careful balance between load time and interactivity. Users expect pages to render within two seconds, and every additional resource increases the risk of abandonment. Prioritize critical rendering paths and defer non-essential assets to improve the perceived speed of your application and keep users engaged. `} css={`.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .column { background: hsl(220 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(260 60% 50%); background: hsl(260 60% 94%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .body-default { font-size: 0.9rem; line-height: 1.6; color: hsl(220 10% 30%); margin: 0; text-wrap: wrap; } .body-pretty { font-size: 0.9rem; line-height: 1.6; color: hsl(220 10% 30%); margin: 0; text-wrap: pretty; }`} /> 4 lines — balance works Building responsive layouts that adapt gracefully to any screen size is one of the most important skills in modern front-end development. Container queries and fluid typography make this easier than ever before. 10+ lines — balance stops working Building responsive layouts that adapt gracefully to any screen size is one of the most important skills in modern front-end development. Container queries and fluid typography make this easier than ever before. Developers should consider the full range of devices their users might have, from narrow mobile screens to ultra-wide desktop monitors. A robust layout system uses relative units, logical properties, and flexible grids rather than fixed pixel values. Testing across multiple viewport sizes during development catches layout issues early and ensures a consistent user experience. Progressive enhancement means the core content remains accessible even when advanced layout features are not supported by the browser. `} css={`.limit-demo { display: flex; flex-direction: column; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .block { background: hsl(160 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(160 60% 35%); background: hsl(160 50% 90%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .balanced-text { font-size: 0.9rem; line-height: 1.6; color: hsl(160 10% 25%); margin: 0; text-wrap: balance; }`} /> Getting Started with CSS Container Queries for Responsive Components Container queries let you style elements based on the size of their parent container rather than the viewport. This enables truly reusable, context-aware components. Why Container Queries Matter Traditional media queries respond to the viewport width, which means the same component can look different depending on where it sits in the page layout. A card component in a narrow sidebar needs different styles than the same card in a wide content area, but viewport-based breakpoints cannot distinguish between the two contexts. Container queries solve this by letting the component respond to its own container size. This makes components self-contained and portable across different layout contexts without any modification. `} css={`.article { max-width: 600px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; } .article-title { font-size: 1.5rem; line-height: 1.25; color: hsl(220 25% 15%); margin: 0 0 0.75rem; text-wrap: balance; } .article-lead { font-size: 1.05rem; line-height: 1.5; color: hsl(220 15% 40%); margin: 0 0 1.5rem; text-wrap: pretty; border-left: 3px solid hsl(220 60% 60%); padding-left: 1rem; } .article-heading { font-size: 1.15rem; line-height: 1.3; color: hsl(220 25% 20%); margin: 1.25rem 0 0.5rem; text-wrap: balance; } .article-body { font-size: 0.9rem; line-height: 1.65; color: hsl(220 10% 30%); margin: 0 0 0.75rem; text-wrap: pretty; }`} /> This heading uses text-wrap balance for even line distribution In browsers that do not support text-wrap: balance or pretty, these properties are simply ignored. The text renders with default wrapping behavior — no errors, no broken layouts, just the normal line-breaking you would get without the property. `} css={`.enhancement-demo { padding: 1.5rem; font-family: system-ui, sans-serif; } .card { background: hsl(40 40% 96%); border-radius: 8px; padding: 1.25rem; border: 1px solid hsl(40 30% 88%); } .card-heading { font-size: 1.15rem; line-height: 1.3; color: hsl(40 50% 25%); margin: 0 0 0.75rem; /* Graceful degradation: ignored in unsupported browsers */ text-wrap: balance; } .card-body { font-size: 0.9rem; line-height: 1.6; color: hsl(40 10% 30%); margin: 0; /* Graceful degradation: ignored in unsupported browsers */ text-wrap: pretty; }`} /> ## CJK and Japanese Text Considerations `text-wrap: balance` and `text-wrap: pretty` were designed with alphabetic languages in mind and behave unexpectedly with Japanese and other CJK (Chinese, Japanese, Korean) text. If you apply these properties globally to headings or paragraphs, Japanese text will often render with awkward line breaks and unusually large trailing whitespace on the right side. ### Why It Breaks In alphabetic languages, the browser uses spaces and punctuation to identify word boundaries when redistributing line breaks. Japanese has no spaces between words — every character position is a valid break point. When `text-wrap: balance` tries to equalize line lengths, it splits text at arbitrary character positions, producing breaks that cut through the middle of natural phrases. The result is a heading where the first line ends mid-phrase, leaving a conspicuously large gap on the right. English Default Designing Accessible User Interfaces for Modern Web Applications text-wrap: balance Designing Accessible User Interfaces for Modern Web Applications 日本語 Default モダンウェブ開発のためのアクセシブルなユーザーインターフェース設計 text-wrap: balance モダンウェブ開発のためのアクセシブルなユーザーインターフェース設計 `} css={`.lang-comparison { display: flex; flex-direction: column; gap: 1rem; padding: 1rem 1.25rem; font-family: system-ui, sans-serif; } .lang-block { background: hsl(220 20% 97%); border-radius: 8px; padding: 0.75rem 1rem; } .lang-label { display: inline-block; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; color: hsl(220 50% 45%); background: hsl(220 60% 92%); padding: 0.15rem 0.5rem; border-radius: 3px; margin-bottom: 0.6rem; } .lang-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .lang-col { background: hsl(0 0% 100%); border-radius: 6px; padding: 0.75rem; border: 1px solid hsl(220 20% 90%); } .variant-label { display: block; font-size: 0.68rem; font-weight: 600; color: hsl(220 30% 55%); margin-bottom: 0.4rem; font-family: monospace; } .heading { font-size: 1rem; line-height: 1.4; color: hsl(220 20% 20%); margin: 0; } .heading--default { text-wrap: wrap; } .heading--balanced { text-wrap: balance; }`} /> The Japanese balanced version often breaks at an unnatural position — for example splitting after a particle like 「の」 or 「な」 — because the browser has no awareness of Japanese word boundaries. The line break feels arbitrary, and the right margin of the first line becomes conspicuously large. ### The word-break: auto-phrase Workaround Chrome 119+ introduced `word-break: auto-phrase`, which uses the [BudouX](https://github.com/google/budoux) machine learning engine to identify natural phrase boundaries (文節) in Japanese text. When combined with `text-wrap: balance`, the browser balances around those phrase boundaries instead of arbitrary character positions. ```css h1, h2, h3 { text-wrap: balance; word-break: auto-phrase; /* Chrome 119+ — improves Japanese line breaks */ } ``` balance only パフォーマンス最適化のためのレンダリングパイプラインの理解と改善手法 balance + auto-phrase パフォーマンス最適化のためのレンダリングパイプラインの理解と改善手法 `} css={`.autophrase-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1.25rem; font-family: system-ui, sans-serif; } .autophrase-col { background: hsl(260 20% 97%); border-radius: 8px; padding: 1rem; border: 1px solid hsl(260 20% 90%); } .autophrase-label { display: block; font-size: 0.7rem; font-weight: 600; color: hsl(260 50% 45%); font-family: monospace; margin-bottom: 0.5rem; } .heading-balance { font-size: 1.05rem; line-height: 1.5; color: hsl(260 20% 20%); margin: 0; text-wrap: balance; } .heading-autophrase { font-size: 1.05rem; line-height: 1.5; color: hsl(260 20% 20%); margin: 0; text-wrap: balance; word-break: auto-phrase; }`} /> Note that `word-break: auto-phrase` requires `lang="ja"` on the element or an ancestor to activate Japanese phrase detection. Without the `lang` attribute, the property has no effect. Firefox does not yet support `auto-phrase`. Safari support is limited — check current browser compatibility before relying on it. ### Locale-Scoped Fallback Pattern For Japanese or multilingual sites, the safest approach is to reset `text-wrap` for Japanese content via the `lang` attribute selector: ```css /* Apply balance globally */ h1, h2, h3 { text-wrap: balance; word-break: auto-phrase; /* helps in Chrome */ } /* Reset for Japanese where balance misbehaves without auto-phrase support */ [lang="ja"] :is(h1, h2, h3) { text-wrap: initial; } /* Re-enable balance in Chrome which supports auto-phrase */ @supports (word-break: auto-phrase) { [lang="ja"] :is(h1, h2, h3) { text-wrap: balance; } } ``` lang="en" Designing Accessible User Interfaces for Modern Applications Container queries let you style elements based on their parent size. This enables truly context-aware components that adapt to any layout without viewport-based media queries. lang="ja" モダンアプリケーションのためのアクセシブルなインターフェース設計 コンテナクエリを使用すると、ビューポートではなく親要素のサイズに基づいてスタイルを適用できます。これにより、どのレイアウトにも適応できる、真にコンテキストに対応したコンポーネントが実現します。 `} css={`.locale-demo { display: flex; flex-direction: column; gap: 1rem; padding: 1.25rem; font-family: system-ui, sans-serif; } .locale-section { background: hsl(220 20% 97%); border-radius: 8px; padding: 1rem 1.25rem; border: 1px solid hsl(220 20% 91%); } .locale-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; font-family: monospace; padding: 0.15rem 0.5rem; border-radius: 3px; margin-bottom: 0.6rem; } .locale-badge--en { color: hsl(200 60% 35%); background: hsl(200 60% 90%); } .locale-badge--ja { color: hsl(20 60% 35%); background: hsl(20 60% 90%); } .article-heading { font-size: 1.1rem; line-height: 1.4; color: hsl(220 20% 20%); margin: 0 0 0.5rem; } .article-heading[lang="en"] { text-wrap: balance; } .article-heading[lang="ja"] { text-wrap: initial; word-break: auto-phrase; } @supports (word-break: auto-phrase) { .article-heading[lang="ja"] { text-wrap: balance; } } .article-body { font-size: 0.88rem; line-height: 1.65; color: hsl(220 10% 35%); margin: 0; text-wrap: pretty; }`} /> ### Summary | Property | English | Japanese | |---|---|---| | `text-wrap: balance` | Works well | Breaks at arbitrary character positions | | `text-wrap: pretty` | Works well | Largely harmless but orphan concept doesn't map well to CJK | | `word-break: auto-phrase` | No effect | Improves phrase-aware breaking (Chrome 119+ only) | **Recommendation**: For Japanese or multilingual projects, avoid applying `text-wrap: balance` to headings unconditionally. Either skip it for Japanese content, use the locale-scoped `@supports` pattern above, or accept that the improvement will only appear in Chrome. ## Browser Support - **`text-wrap: balance`** — Supported in Chrome 114+, Edge 114+, Firefox 121+, and Safari 17.5+. Widely available as of 2025. - **`text-wrap: pretty`** — Supported in Chrome 117+, Edge 117+, and Safari 17.4+. Firefox support is still pending as of early 2026. - **`text-wrap: stable`** — Supported in Chrome 120+, Edge 120+, and Firefox 121+. Safari support is pending. All three values degrade gracefully — unsupported browsers simply use the default `text-wrap: wrap` behavior, so there is no risk in applying them today. ## Common AI Mistakes - Applying `text-wrap: balance` to long body text paragraphs — it only works on blocks of approximately 6 lines or fewer, and even when it does apply, it creates an unnaturally narrow text block - Using JavaScript to calculate line breaks and insert `` tags when `text-wrap: balance` solves the same problem natively - Inserting ` ` between the last two words to prevent orphans — this is fragile and breaks at different viewport widths. Use `text-wrap: pretty` instead - Confusing `text-wrap: balance` with `text-align: center` — balance adjusts line break positions to equalize line lengths, it does not change alignment - Not setting `text-wrap: balance` on heading elements by default — this should be a baseline style for all heading levels - Applying `text-wrap: balance` to elements that need a specific width, like navigation items or badges — balance can change the intrinsic width of the element unexpectedly ## When to Use ### text-wrap: balance - Headings (`h1` through `h6`) - Pull quotes and blockquote text - Captions and figcaptions - Card titles and hero text - Any short text block (6 lines or fewer) where visual symmetry matters ### text-wrap: pretty - Body paragraphs - List items with wrapping text - Descriptions and summaries - Any long-form text where you want to avoid a single orphaned word on the last line ### text-wrap: stable - `contenteditable` elements - Live text editors and input areas - Chat message composition fields - Any context where text is being actively edited and re-wrapping would be disruptive ## References - [MDN: text-wrap](https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap) - [Chrome Developers: CSS text-wrap: balance](https://developer.chrome.com/blog/css-text-wrap-balance) - [Chrome Developers: CSS text-wrap: pretty](https://developer.chrome.com/blog/css-text-wrap-pretty) - [web.dev: CSS text-wrap: balance](https://web.dev/articles/css-text-wrap-balance) - [Ahmad Shadeed: CSS text-wrap balance](https://ishadeed.com/article/css-text-wrap-balance) --- # gap vs margin > Source: https://takazudomodular.com/pj/zcss/docs/layout/flexbox-and-grid/gap-vs-margin ## The Problem Spacing between elements is one of the most common CSS tasks, yet AI agents frequently reach for `margin` in every situation — even inside flex and grid containers where `gap` is the correct tool. Margin-based spacing creates several problems: double margins where items meet, first/last child workarounds, margin collapse in block flow, and inconsistent spacing when items wrap. The `gap` property, available in flexbox, grid, and multi-column layouts, solves all of these issues. ## The Solution Use `gap` for spacing **between** sibling items inside a flex, grid, or multi-column container. Use `margin` for spacing **around** elements or for spacing between elements that are not siblings in the same layout container. The key distinction: `gap` only creates space between items, never before the first or after the last. Margins create space around every element, requiring workarounds to avoid double spacing and edge spacing. ## Code Examples ### The Margin Problem ```css /* Margin creates spacing issues */ .list-item { margin-bottom: 1rem; } /* Problem 1: Last item has unwanted bottom margin */ /* Fix attempt: */ .list-item:last-child { margin-bottom: 0; } ``` ```css /* Horizontal layout with margin */ .tag { margin-right: 0.5rem; } /* Problem 2: Last item has unwanted right margin */ .tag:last-child { margin-right: 0; } ``` ### The Gap Solution ```css /* Gap only creates space BETWEEN items */ .tag-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } /* No :last-child workaround needed. No double spacing. */ ``` ```html CSS Grid Flexbox ``` With margin (extra space on last item): CSS Grid Flexbox Layout Each item has margin-right — last item pushes container edge With gap (clean spacing): CSS Grid Flexbox Layout gap only adds space between items — no edge overflow`} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; color: #334155; margin-bottom: 8px; } .note { font-family: system-ui, sans-serif; font-size: 12px; color: #64748b; margin-bottom: 16px; } .row-margin { display: flex; background: #fee2e2; padding: 8px; border-radius: 8px; margin-bottom: 4px; } .row-gap { display: flex; gap: 10px; background: #dcfce7; padding: 8px; border-radius: 8px; margin-bottom: 4px; } .tag { padding: 8px 16px; border-radius: 6px; font-family: system-ui, sans-serif; font-size: 14px; color: #fff; } .margin-tag { background: #ef4444; margin-right: 10px; } .gap-tag { background: #22c55e; }`} /> ### Margin Collapse in Block Flow Vertical margins between block elements collapse — the browser uses the larger margin, not the sum. ```css .heading { margin-bottom: 1rem; } .paragraph { margin-top: 1.5rem; } /* The space between heading and paragraph is 1.5rem, not 2.5rem */ ``` This is by design but frequently confuses AI agents, which may add extra margins to compensate for what they perceive as "missing" space. ### When Margin Collapse Does Not Happen Margin collapse only occurs in normal block flow. It does **not** occur: - Inside flex containers - Inside grid containers - On floated elements - On absolutely positioned elements - When a parent has `overflow` other than `visible` - When a parent has padding or border on the collapsing edge ```css /* No margin collapse: items are flex children */ .flex-container { display: flex; flex-direction: column; } .flex-container > .item { margin-block: 1rem; /* Adjacent margins DO NOT collapse. Total space = 2rem between items. */ } ``` ### Gap in Grid Layouts ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 1.5rem; } ``` All cards have exactly 1.5rem of space between them — horizontally and vertically. No margin workarounds needed. Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={`.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 8px; } .card { background: #8b5cf6; color: #fff; padding: 20px 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; }`} /> ### Different Row and Column Gaps ```css .layout { display: grid; grid-template-columns: 1fr 1fr 1fr; row-gap: 2rem; column-gap: 1rem; /* Or shorthand: gap: 2rem 1rem; */ } ``` ### Gap in Flex Layouts ```css .toolbar { display: flex; align-items: center; gap: 0.75rem; } ``` ```html Save Cancel Delete ``` Every item has exactly 0.75rem between it and its neighbor. No margin rules, no `:first-child` or `:last-child` overrides. ### Combining Gap and Margin Gap and margin serve different purposes and can be used together: ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 1.5rem; /* Space between cards */ margin-block-end: 3rem; /* Space after the entire grid */ } .card { padding: 1.5rem; /* Space inside each card */ /* No margin needed — gap handles inter-card spacing */ } ``` ### When Margin Is Still the Right Choice ```css /* Centering a block element */ .container { max-inline-size: 1200px; margin-inline: auto; } /* Space between unrelated sections */ .section + .section { margin-block-start: 4rem; } /* Space between elements that are NOT siblings in a flex/grid container */ .page-title { margin-block-end: 2rem; } ``` Block flow (margins collapse): margin-bottom: 20px margin-top: 30px Gap = 30px (not 50px) — margins collapsed Flex column (margins do NOT collapse): margin-bottom: 20px margin-top: 30px Gap = 50px — both margins apply `} css={`.demo { font-family: system-ui, sans-serif; font-size: 13px; padding: 8px; } .label { font-weight: 600; font-size: 14px; color: #334155; margin-bottom: 8px; } .block-flow { background: #fef3c7; padding: 12px; border-radius: 8px; margin-bottom: 16px; } .flex-flow { display: flex; flex-direction: column; background: #dbeafe; padding: 12px; border-radius: 8px; } .box { padding: 10px 14px; border-radius: 6px; color: #fff; font-size: 13px; } .box.a { background: #f59e0b; margin-bottom: 20px; } .box.b { background: #f59e0b; margin-top: 30px; } .box.c { background: #3b82f6; margin-bottom: 20px; } .box.d { background: #3b82f6; margin-top: 30px; } .annotation { font-size: 12px; color: #64748b; margin-top: 8px; text-align: center; }`} /> ## Common AI Mistakes - **Using `margin` on flex/grid children instead of `gap` on the container.** This is the most common mistake. When items are in a flex or grid container, `gap` is simpler, more predictable, and avoids double-spacing issues. - **Not understanding margin collapse.** AI agents add `margin-top: 1rem` and `margin-bottom: 1rem` to adjacent elements, expecting 2rem of space. In block flow, they collapse to 1rem. This leads to confused debugging and arbitrary margin increases. - **Using negative margins to "fix" gap issues.** A classic anti-pattern: applying `margin: -0.5rem` on the container to counteract `margin: 0.5rem` on items. `gap` eliminates this entirely. - **Adding `:last-child { margin: 0 }` workarounds.** If you find yourself removing margins on first or last children, you should be using `gap` instead. - **Using `gap` on non-flex/grid containers.** `gap` only works on `display: flex`, `display: grid`, and `display: multi-column` containers. On a regular block element, it has no effect. - **Forgetting that flex/grid containers disable margin collapse.** When switching an element from block to flex/grid, existing margin-based spacing may double because collapse no longer occurs. - **Using `margin` for spacing inside a component and `gap` between components, or vice versa.** Be consistent: use `gap` inside layout containers (flex/grid), and `margin` for external spacing between sections or unrelated elements. ## When to Use ### Use gap when - Spacing children of a flex container (navigation items, buttons, tags) - Spacing children of a grid container (card grids, form layouts) - You want consistent spacing between items without first/last child workarounds - Items may wrap and you want consistent gaps on all rows ### Use margin when - Centering block elements (`margin-inline: auto`) - Spacing between sections or unrelated elements that are not flex/grid siblings - Spacing around a component from its surrounding content - Working with elements in normal block flow where you understand and want margin collapse behavior ### Use padding when - Creating space inside an element (between content and its border) - You need the space to be part of the element's background/clickable area ## Tailwind CSS Tailwind makes `gap` the natural choice with its `gap-*` utilities on flex and grid containers. ### Flex Gap Flex with gap-2.5: CSS Grid Flexbox Layout `} height={110} /> ### Grid Gap Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ## References - [gap - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/gap) - [CSS gap property vs. margin property - LogRocket Blog](https://blog.logrocket.com/css-gap-vs-margin/) - [The Rules of Margin Collapse - Josh W. Comeau](https://www.joshwcomeau.com/css/rules-of-margin-collapse/) - [Spacing in CSS - Ahmad Shadeed](https://ishadeed.com/article/spacing-in-css/) - [Spacing - web.dev](https://web.dev/learn/css/spacing) --- # Anchor Positioning > Source: https://takazudomodular.com/pj/zcss/docs/layout/positioning/anchor-positioning ## The Problem Positioning elements relative to other elements — tooltips, popovers, dropdown menus, labels — has always required JavaScript. Libraries like Floating UI (Popper.js) calculate positions, handle viewport overflow, and reposition on scroll/resize. This adds bundle size, complexity, and frame-rate concerns. AI agents consistently recommend JavaScript positioning libraries when pure CSS anchor positioning would be more performant and maintainable. ## The Solution CSS Anchor Positioning lets you declaratively position an element relative to an "anchor" element using pure CSS. It handles the anchor relationship (`anchor-name` / `position-anchor`), positioning (`position-area` or the `anchor()` function), and automatic fallback repositioning when the element overflows the viewport (`position-try-fallbacks`). Combined with the Popover API, this replaces most JavaScript tooltip/dropdown libraries. ## Code Examples ### Basic Tooltip ```css .anchor-button { anchor-name: --my-tooltip-anchor; } .tooltip { /* Attach to the anchor */ position: fixed; position-anchor: --my-tooltip-anchor; /* Position above the anchor, centered */ position-area: top center; /* Spacing from the anchor */ margin-bottom: 8px; /* Styling */ background: #1f2937; color: white; padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.875rem; white-space: nowrap; } ``` ```html Hover me Helpful tooltip text ``` ### Dropdown Menu ```css .menu-trigger { anchor-name: --menu-anchor; } .dropdown-menu { position: fixed; position-anchor: --menu-anchor; /* Below the trigger, left-aligned */ position-area: bottom span-right; margin-top: 4px; background: white; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); padding: 0.25rem; min-width: 180px; } ``` ```html Menu Option 1 Option 2 Option 3 ``` ### Automatic Fallback Positioning When the positioned element would overflow the viewport, automatically try alternative positions. ```css .tooltip { position: fixed; position-anchor: --tooltip-anchor; position-area: top center; margin: 8px; /* If top overflows, try bottom, then right, then left */ position-try-fallbacks: flip-block, flip-inline; } ``` ### Custom Fallback Positions with `@position-try` ```css @position-try --below { position-area: bottom center; margin-top: 8px; } @position-try --right { position-area: right center; margin-left: 8px; } @position-try --left { position-area: left center; margin-right: 8px; } .tooltip { position: fixed; position-anchor: --tooltip-anchor; /* Default: above */ position-area: top center; margin-bottom: 8px; /* Try these in order if default overflows */ position-try-fallbacks: --below, --right, --left; } ``` ### Using the `anchor()` Function for Precise Placement For more control than `position-area` provides, use the `anchor()` function inside inset properties. ```css .popover { position: fixed; position-anchor: --trigger; /* Position the popover's top-left corner at the anchor's bottom-left corner */ top: anchor(bottom); left: anchor(left); /* Or center horizontally relative to anchor */ left: anchor(center); translate: -50% 0; } ``` ### Connecting Label to Form Field ```css .form-field { anchor-name: --field; } .field-hint { position: fixed; position-anchor: --field; position-area: right center; margin-left: 12px; font-size: 0.75rem; color: #6b7280; max-width: 200px; } ``` ```html We'll never share your email. ``` ### Dynamic Anchors with `anchor-name` on Multiple Elements ```css .list-item { anchor-name: --item; } .list-item:hover { /* The detail panel follows whichever item is hovered */ } .detail-panel { position: fixed; position-anchor: --item; position-area: right center; margin-left: 1rem; } ``` ### Combining with Popover API ```css [popovertarget] { anchor-name: --popover-trigger; } [popover] { position: fixed; position-anchor: --popover-trigger; position-area: bottom center; margin-top: 4px; /* Automatic fallback */ position-try-fallbacks: flip-block; /* Styling */ border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); } ``` ```html Info Additional Information This popover is positioned and managed entirely in CSS/HTML. ``` ## Browser Support - Chrome 125+ - Edge 125+ - Safari 26+ (Technology Preview, shipping 2025-2026) - Firefox 145+ (behind a flag), fully shipped in Firefox 150+ As of late 2025, anchor positioning is available in all major browsers. For older browsers, the [CSS anchor positioning polyfill](https://github.com/nicejose/css-anchor-positioning) by OddBird supports Chrome 51+, Firefox 54+, and Safari 10+. Always provide a reasonable non-positioned fallback. ## Common AI Mistakes - Recommending JavaScript positioning libraries (Floating UI, Popper.js) for tooltips and popovers when CSS anchor positioning handles it natively - Not knowing that CSS anchor positioning exists - Using `position: absolute` with manual `top`/`left` calculations instead of `position-area` - Forgetting `position: fixed` on the anchored element (required for anchor positioning to work) - Not using `position-try-fallbacks` for viewport overflow handling — the element gets clipped - Confusing the old `inset-area` property name with the current `position-area` (renamed in Chrome 129) - Not combining anchor positioning with the Popover API for accessible disclosure patterns ## When to Use - Tooltips, popovers, and info panels positioned relative to a trigger - Dropdown menus that need to reposition when near viewport edges - Form field hints and validation messages positioned beside inputs - Floating labels and annotation markers - Any UI pattern that previously required JavaScript to position one element relative to another ## Live Previews Anchor Button I'm positioned with CSS anchor-name and position-anchor! How it works: The button declares anchor-name: --btn-anchor. The tooltip uses position-anchor: --btn-anchor and position-area: top center to sit above the button. Note: CSS Anchor Positioning requires Chrome 125+, Edge 125+, or Firefox 150+. If the tooltip appears below the button instead of above it, your browser may not support this feature yet. `} css={` .demo { font-family: system-ui, sans-serif; padding: 3rem 1rem 1rem; } .button-row { display: flex; justify-content: center; position: relative; min-height: 100px; } .anchor-btn { anchor-name: --btn-anchor; padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; align-self: flex-end; } .tooltip { position: fixed; position-anchor: --btn-anchor; position-area: top center; margin-bottom: 8px; background: #1e293b; color: #f8fafc; padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.8rem; max-width: 240px; text-align: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .tooltip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: #1e293b; } .code-note { margin-top: 1.5rem; padding: 1rem; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; font-size: 0.8rem; color: #64748b; line-height: 1.5; } .code-note p { margin: 0 0 0.5rem; } .code-note p:last-child { margin: 0; } code { background: #e2e8f0; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} height={340} /> ## References - [CSS anchor positioning - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning) - [CSS Anchor Positioning Guide - CSS-Tricks](https://css-tricks.com/css-anchor-positioning-guide/) - [The CSS anchor positioning API - Chrome for Developers](https://developer.chrome.com/docs/css-ui/anchor-positioning-api) - [Anchor positioning - web.dev](https://web.dev/learn/css/anchor-positioning) - [CSS Anchor Positioning - Can I Use](https://caniuse.com/css-anchor-positioning) --- # Logical Properties > Source: https://takazudomodular.com/pj/zcss/docs/layout/sizing/logical-properties ## The Problem CSS was originally designed with physical directions: `top`, `right`, `bottom`, `left`. These work for left-to-right (LTR) languages but break in right-to-left (RTL) languages like Arabic and Hebrew, and in vertical writing modes like Japanese. AI agents almost always generate physical properties (`margin-left`, `padding-right`, `border-top`) even when logical alternatives exist. This creates layouts that require manual mirroring for internationalization (i18n), which is error-prone and produces duplicate CSS. ## The Solution CSS logical properties define spacing, sizing, and positioning in terms of the content flow rather than physical screen directions: - **Inline axis** — the direction text flows (horizontal in LTR/RTL, vertical in vertical writing modes) - **Block axis** — the direction blocks stack (vertical in LTR/RTL, horizontal in vertical writing modes) By using `margin-inline`, `padding-block`, `border-inline-start`, and similar properties, your layouts automatically adapt to any writing direction. ## Code Examples ### Physical vs Logical Property Mapping ```css /* Physical (LTR-only) */ .card-physical { margin-left: 1rem; margin-right: 1rem; padding-top: 2rem; padding-bottom: 2rem; border-left: 3px solid blue; } /* Logical (all writing directions) */ .card-logical { margin-inline: 1rem; padding-block: 2rem; border-inline-start: 3px solid blue; } ``` In LTR, these produce identical results. In RTL, the logical version automatically places the border on the right side (the start of the inline axis in RTL). margin-left + margin-right (physical): margin-left: 40px; margin-right: 40px margin-inline: 40px (logical): margin-inline: 40px margin-inline in RTL (auto-adapts): margin-inline: 40px (RTL text here) `} css={`.demo { font-family: system-ui, sans-serif; font-size: 13px; padding: 8px; } .label { font-weight: 600; font-size: 14px; color: #334155; margin-bottom: 6px; } .outer { background: #f1f5f9; border-radius: 8px; padding: 8px 0; margin-bottom: 12px; } .box { padding: 12px 16px; border-radius: 6px; color: #fff; font-size: 13px; } .physical { background: #3b82f6; margin-left: 40px; margin-right: 40px; } .logical { background: #22c55e; margin-inline: 40px; } .rtl-box { background: #8b5cf6; }`} /> ### Complete Property Mapping | Physical Property | Logical Property | |---|---| | `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` | | `width` | `inline-size` | | `height` | `block-size` | | `min-width` | `min-inline-size` | | `max-height` | `max-block-size` | | `top` | `inset-block-start` | | `bottom` | `inset-block-end` | | `left` | `inset-inline-start` | | `right` | `inset-inline-end` | | `border-top` | `border-block-start` | | `border-bottom` | `border-block-end` | | `text-align: left` | `text-align: start` | | `text-align: right` | `text-align: end` | | `float: left` | `float: inline-start` | ### Shorthand Properties ```css .box { /* Sets both inline-start and inline-end */ margin-inline: 1rem; /* same as margin-left + margin-right in LTR */ /* Sets both block-start and block-end */ padding-block: 2rem; /* same as padding-top + padding-bottom in LTR */ /* Two-value syntax: start and end */ margin-inline: 1rem 2rem; /* inline-start: 1rem, inline-end: 2rem */ padding-block: 1rem 0; /* block-start: 1rem, block-end: 0 */ } ``` ### Centering with margin-inline: auto ```css .centered-block { max-inline-size: 800px; margin-inline: auto; } ``` This is the logical equivalent of `max-width: 800px; margin-left: auto; margin-right: auto`. It works correctly in all writing modes. ### Navigation with Logical Spacing ```css .nav-item { padding-inline: 1rem; padding-block: 0.5rem; border-inline-end: 1px solid #e5e7eb; } .nav-item:last-child { border-inline-end: none; } ``` In LTR, the border appears on the right of each item. In RTL, it automatically appears on the left. ### Icon with Logical Margin ```css .button-icon { margin-inline-end: 0.5rem; } ``` ```html Submit ``` In LTR, the icon has a right margin separating it from the text. In RTL, the margin automatically moves to the left side. ### Logical Positioning with inset ```css .dropdown { position: absolute; inset-block-start: 100%; inset-inline-start: 0; } ``` This positions the dropdown below its parent (`top: 100%` in LTR) and aligned to the start edge (`left: 0` in LTR, `right: 0` in RTL). ### Using inline-size and block-size ```css .sidebar { inline-size: 250px; /* width in horizontal writing modes */ block-size: 100%; /* height in horizontal writing modes */ } .hero { min-block-size: 100vh; /* min-height in horizontal writing modes */ inline-size: 100%; /* width in horizontal writing modes */ } ``` padding-block: 2rem (top and bottom padding) padding-inline: 2rem (left and right padding) padding-block: 1rem; padding-inline: 2rem `} css={`.demo { display: flex; flex-direction: column; gap: 12px; padding: 8px; font-family: system-ui, sans-serif; font-size: 13px; } .box { background: #dbeafe; border-radius: 8px; padding-block: 2rem; } .box.inline { background: #dcfce7; padding-block: 0; padding-inline: 2rem; } .box.both { background: #fef3c7; padding-block: 1rem; padding-inline: 2rem; } .inner { background: #3b82f6; color: #fff; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 13px; }`} /> ## Common AI Mistakes - **Always using physical properties.** AI agents default to `margin-left`, `padding-top`, `border-right` etc. even in projects that already use logical properties or need i18n support. Once a project adopts logical properties, all new CSS should be consistent. - **Mixing physical and logical properties on the same element.** This creates confusing, hard-to-maintain CSS. If you use `margin-inline`, do not also use `margin-left` on the same element. - **Not using `margin-inline: auto` for centering.** `margin: 0 auto` resets vertical margins to 0 as a side effect. `margin-inline: auto` only affects the inline axis, preserving any existing block-axis margins. - **Using `text-align: left` instead of `text-align: start`.** In RTL contexts, `left` does not flip. Use `start` and `end` for flow-relative alignment. - **Treating logical properties as "only for i18n."** Even in LTR-only projects, logical properties are more readable and future-proof. `padding-block` clearly communicates "vertical padding" in a horizontal writing mode without requiring readers to mentally map "top and bottom." - **Using `width` and `height` when `inline-size` and `block-size` would be clearer.** For layout properties that relate to content flow, the logical equivalents communicate intent better. ## When to Use ### Always prefer logical properties when - The project supports or may support RTL languages (Arabic, Hebrew, Persian) - The project uses vertical writing modes (CJK content) - Writing new CSS in a project that already uses logical properties - Centering with auto margins (`margin-inline: auto` is cleaner than `margin: 0 auto`) ### Physical properties are still fine when - The property truly relates to physical screen position (e.g., a fixed element pinned to the visual top of the screen) - The project has no i18n requirements and the team has not adopted logical properties yet - Working with `top`/`left` in `position: fixed` contexts where physical positioning is intentional ### Adopt gradually - Start with new components and refactor existing ones over time - Use Stylelint rules (e.g., `liberty/use-logical-spec`) to enforce logical properties in new code - Priority: high-usage components, navigation, forms, and design system tokens ## References - [CSS Logical Properties and Values - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values) - [Logical Properties - web.dev](https://web.dev/learn/css/logical-properties) - [Logical properties for margins, borders, and padding - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values/Margins_borders_padding) - [From margin-left to margin-inline: Why Logical CSS Properties Matter - Brett Dorrans](https://lapidist.net/articles/2025/from-margin-left-to-margin-inline-why-logical-css-properties-matter/) --- # Table Cell Width Control > Source: https://takazudomodular.com/pj/zcss/docs/layout/specialized/table-cell-width-control ## The Problem Tables with mixed content types — text columns alongside image columns, status badges, or action buttons — often render with unpredictable widths. The browser's automatic table layout algorithm distributes space based on content, which means a text-heavy column expands to fill available space while an image column gets more room than it needs. AI agents often try to fix this with `width` on `` elements, which the browser treats as a suggestion and frequently ignores. The result is columns that are either too wide or too narrow, with no reliable way to say "this column should be exactly as wide as its content" or "this column needs at least 200px but no more." ## The Solution There are two complementary techniques for precise table column width control: ### Technique 1: The `width: 0` Shrink-to-Content Trick Setting a cell's width to `0` (or `1px` / `0.1%`) tells the browser "make this column as narrow as possible." The column then shrinks to fit its content exactly — perfect for columns containing images, icons, badges, or action buttons where you want no wasted space. ```css /* Column shrinks to fit its content exactly */ td.shrink { width: 0; white-space: nowrap; /* Prevent content from wrapping to shrink further */ } ``` ### Technique 2: Inner Element for Minimum Width To enforce a minimum width, place a `` inside the cell with a fixed `width`. Combined with `width: 0` on the cell itself, this creates a "minimum width" effect — the column will be at least as wide as the inner ``, but won't expand beyond what the content requires. ```css /* Cell wants to be as small as possible */ td.min-width-cell { width: 0; } /* Inner div enforces minimum width */ td.min-width-cell .cell-inner { width: 200px; /* Column will be at least 200px */ } ``` ### Technique 3: `table-layout: fixed` for Full Control For maximum control, use `table-layout: fixed`. With this property, the browser determines column widths from the first row only (ignoring content in subsequent rows), and respects explicit `width` values set on cells or `` elements. ```css table { table-layout: fixed; width: 100%; } ``` The tradeoff: `table-layout: fixed` renders faster (the browser doesn't need to scan all rows), but you must explicitly size every column — unsized columns share remaining space equally. ## Code Examples ### Shrink-to-Content Columns The most common pattern: shrink specific columns (image, status, actions) to fit their content, letting the text column take remaining space. Avatar Name Description Status Alice Johnson Frontend developer working on the design system and component library Active Bob Chen Backend engineer focused on API performance and database optimization Away Carol Martinez Product manager coordinating the Q1 roadmap and sprint planning Active `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-shrink { width: 0; white-space: nowrap; } .avatar { width: 36px; height: 36px; border-radius: 50%; display: block; } .badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 10px; font-size: 0.72rem; font-weight: 600; } .badge--active { background: hsl(145 60% 90%); color: hsl(145 60% 30%); } .badge--away { background: hsl(40 80% 90%); color: hsl(40 60% 30%); }`} /> The Avatar and Status columns shrink to exactly fit their content (the image and badge), while Name and Description share the remaining space naturally. ### Minimum Width with Inner Element When a column needs a guaranteed minimum width — for example, a name column that should always be at least 180px to remain readable — use an inner `` with a fixed width. Product Description Price Image Wireless Keyboard Compact Bluetooth keyboard with backlit keys and rechargeable battery. Compatible with macOS and Windows. $79.99 USB-C Hub 7-in-1 hub with HDMI, SD card, USB-A ports, and 100W pass-through charging for laptops. $49.99 Noise Cancelling Headphones Over-ear headphones with adaptive noise cancellation and 30-hour battery life. $299.99 `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-min-width { width: 0; } .cell-sizer { /* This div enforces the minimum column width */ /* The cell has width:0 so it tries to shrink, */ /* but cannot go below this div's width */ } .col-shrink { width: 0; white-space: nowrap; } .product-img { width: 60px; height: 40px; object-fit: cover; border-radius: 4px; display: block; }`} /> The Product column is always at least 180px wide (set by the `cell-sizer` div in the header), while Price and Image columns shrink to their content. The Description column fills the remaining space. ### Combined Pattern: Fixed + Shrink + Flexible A realistic data table combining all techniques: a fixed-width ID column, shrink-to-content columns for avatar and actions, and flexible text columns. # Avatar Name Role Actions 001 Alice Johnson Senior Frontend Developer — Design System Lead Edit Del 002 Bob Chen Backend Engineer — API & Infrastructure Edit Del 003 Carol Martinez Product Manager — Platform & Growth Edit Del `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-min-width { width: 0; } .cell-sizer { /* Enforces minimum column width */ } .col-shrink { width: 0; white-space: nowrap; } .cell-mono { font-family: ui-monospace, monospace; color: hsl(220 15% 50%); } .avatar { width: 32px; height: 32px; border-radius: 50%; display: block; } .btn { padding: 0.25rem 0.5rem; border: 1px solid hsl(220 20% 85%); border-radius: 4px; background: hsl(0 0% 100%); font-size: 0.72rem; font-weight: 500; cursor: pointer; } .btn--edit { color: hsl(220 60% 50%); } .btn--del { color: hsl(0 60% 50%); }`} /> ### table-layout: fixed Alternative `table-layout: fixed` gives full control but requires sizing every column. Use `` elements to set widths declaratively. ID Name Description Status 001 Wireless Keyboard Compact Bluetooth keyboard with backlit keys and rechargeable battery In Stock 002 USB-C Hub 7-in-1 hub with HDMI and USB-A ports for laptops Low 003 Monitor Stand Adjustable aluminum stand with built-in USB hub and cable management In Stock `} css={`.fixed-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .fixed-table th, .fixed-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fixed-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .cell-mono { font-family: ui-monospace, monospace; color: hsl(220 15% 50%); } .badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 10px; font-size: 0.72rem; font-weight: 600; } .badge--active { background: hsl(145 60% 90%); color: hsl(145 60% 30%); } .badge--away { background: hsl(40 80% 90%); color: hsl(40 60% 30%); }`} /> Note that with `table-layout: fixed`, long text in the Description column is truncated with `text-overflow: ellipsis` — the column width is strictly enforced. ### Choosing the Right Approach | Approach | When to use | Tradeoff | |---|---|---| | `width: 0` on cell | Shrink column to content (images, badges, buttons) | Content must not wrap, or add `white-space: nowrap` | | Inner `` with fixed width | Enforce minimum width while still shrinking | Extra markup needed | | `table-layout: fixed` + `` | Full control over every column | Must size all columns; long content gets truncated | | `table-layout: auto` (default) | Let browser decide based on content | Unpredictable; hard to control | ## Common AI Mistakes - Setting `width: 200px` on a `` and expecting it to be enforced — in `table-layout: auto` (the default), the browser treats this as a suggestion, not a rule - Using `min-width` on table cells and expecting it to work — `min-width` is unreliable on `` elements in most browsers. Use the inner `` technique instead - Applying `table-layout: fixed` without setting `width` on the table — `table-layout: fixed` has no effect unless the table itself has an explicit width - Forgetting `white-space: nowrap` on shrink-to-content columns — without it, the cell content can wrap to a narrower width, defeating the purpose - Using percentage widths on columns and expecting them to add up correctly — percentage widths are relative to the table width and can produce unexpected results when combined with fixed-width columns - Not adding `overflow: hidden; text-overflow: ellipsis` on fixed-layout columns — without these, content can overflow the cell boundary or cause horizontal scrolling ## When to Use ### width: 0 shrink-to-content - Image/avatar columns - Status badge or tag columns - Action button columns - Icon columns - Any column where content has a natural fixed size ### Inner div minimum width - Name/label columns that need a readable minimum width - Columns with varying content length but a guaranteed minimum - ID columns that should be consistently sized ### table-layout: fixed - Data tables with many columns where you need strict control - Tables with long text that should truncate - Performance-critical tables with hundreds of rows (fixed layout renders faster) - Dashboard tables where column widths should never change regardless of content ## References - [MDN: table-layout](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout) - [CSS-Tricks: Faking Min Width on a Table Column](https://css-tricks.com/faking-min-width-on-a-table-column/) - [CSS-Tricks: Fixed Table Layouts](https://css-tricks.com/fixing-tables-long-strings/) - [CSS-Tricks: table-layout](https://css-tricks.com/almanac/properties/t/table-layout/) --- # Cascade Layers > Source: https://takazudomodular.com/pj/zcss/docs/methodology/architecture/cascade-layers ## The Problem CSS specificity wars are one of the most common sources of bugs and frustration. Developers resort to overly specific selectors, `!important`, or naming conventions like BEM to manage specificity conflicts. When integrating third-party CSS (design systems, component libraries, resets), controlling which styles take precedence becomes increasingly difficult. AI agents often generate CSS that creates specificity conflicts or uses `!important` as a fix. ## The Solution The `@layer` at-rule provides explicit control over the cascade. Layer priority is evaluated *before* selector specificity — a simple selector in a later-declared layer always wins over a complex selector in an earlier layer. This eliminates specificity wars by design. The full cascade evaluation order is: origin/importance > inline styles > cascade layers > specificity > source order. ## Code Examples ### Declaring Layer Order The order in which layers are first declared determines their priority. The last layer in the declaration list has the highest priority. ```css /* Declare layer order upfront — this is the recommended pattern */ @layer reset, base, components, utilities; /* Now populate layers in any order */ @layer reset { *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } } @layer base { body { font-family: system-ui, sans-serif; line-height: 1.6; } a { color: #2563eb; } } @layer components { .button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; } .button-primary { background: #2563eb; color: white; } } @layer utilities { .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } .text-center { text-align: center; } } ``` ### Importing Third-Party CSS Into a Layer ```css /* Put third-party styles in a low-priority layer */ @import url("normalize.css") layer(reset); @import url("some-library.css") layer(vendor); @layer reset, vendor, base, components, utilities; ``` ### Layer Priority Overrides Specificity ```css @layer base, components; @layer base { /* High specificity: (0, 2, 1) */ nav ul li.active a.nav-link { color: black; } } @layer components { /* Low specificity: (0, 1, 0) — but this WINS because 'components' is declared after 'base' */ .nav-link { color: blue; } } ``` ### Nested Layers ```css @layer components { @layer card { .card { border: 1px solid #e5e7eb; border-radius: 8px; } } @layer button { .button { padding: 0.5rem 1rem; } } } /* Reference nested layers with dot notation */ @layer components.card { .card { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } } ``` ### Using `revert-layer` The `revert-layer` keyword rolls back a property value to whatever was set in the previous layer. ```css @layer base, theme, overrides; @layer base { a { color: blue; text-decoration: underline; } } @layer theme { a { color: #8b5cf6; text-decoration: none; } } @layer overrides { /* Roll back to the base layer value */ .classic-link { color: revert-layer; text-decoration: revert-layer; /* Result: color is #8b5cf6 from theme? No — revert-layer goes to the PREVIOUS layer. Actually: color reverts to theme's value first, then theme could revert-layer to base's value. */ } } ``` A practical use case for `revert-layer`: ```css @layer defaults, theme; @layer defaults { button { background: #e5e7eb; color: #1f2937; } } @layer theme { button { background: #2563eb; color: white; } /* Opt specific buttons out of theming */ button.no-theme { background: revert-layer; /* Falls back to #e5e7eb */ color: revert-layer; /* Falls back to #1f2937 */ } } ``` ### Unlayered Styles Have Highest Priority Styles not in any layer always win over layered styles. ```css @layer base { p { color: gray; } } /* Unlayered — this wins */ p { color: black; } ``` ## Browser Support - Chrome 99+ - Firefox 97+ - Safari 15.4+ - Edge 99+ Global support exceeds 96%. ## Common AI Mistakes - Using `!important` to override styles instead of managing priority with layers - Not declaring layer order upfront, leading to unpredictable priority based on first-appearance order - Placing third-party/vendor CSS outside of a layer (giving it the highest unlayered priority) - Not knowing that unlayered styles beat all layered styles - Confusing `revert-layer` with `revert` — `revert` rolls back to the user-agent stylesheet, while `revert-layer` rolls back to the previous cascade layer - Generating overly specific selectors when layer ordering solves the priority problem ## When to Use - Managing specificity across a large codebase with multiple style sources - Integrating third-party CSS without specificity conflicts - Establishing a clear CSS architecture (reset, base, components, utilities, overrides) - Replacing `!important` usage with structured layer priority - Building design systems where consumers need to override component styles predictably ## Live Previews This text is styled by two layers with conflicting colors. base layer says: red theme layer says: blue — wins (declared later) `} css={` @layer base, theme; @layer base { .text { color: #ef4444; font-size: 1.25rem; font-weight: 700; padding: 1rem; background: #fef2f2; border-left: 4px solid #ef4444; border-radius: 6px; } } @layer theme { .text { color: #3b82f6; background: #eff6ff; border-left-color: #3b82f6; } } .demo { font-family: system-ui, sans-serif; } .legend { margin-top: 1rem; font-size: 0.85rem; color: #64748b; display: flex; flex-direction: column; gap: 0.35rem; } .legend div { display: flex; align-items: center; gap: 0.5rem; } .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.8rem; } `} /> Home About Contact The components layer uses a simple .nav-link selector, but it wins over the high-specificity base layer selector because it is declared later. `} css={` @layer base, components; @layer base { /* High specificity: (0, 2, 3) */ nav ul li.active a.nav-link { color: #ef4444; text-decoration: line-through; } } @layer components { /* Low specificity: (0, 1, 0) — but WINS */ .nav-link { color: #2563eb; text-decoration: none; font-weight: 600; padding: 0.5rem 1rem; border-radius: 6px; transition: background 0.2s; } .nav-link:hover { background: #eff6ff; } } nav { font-family: system-ui, sans-serif; } ul { list-style: none; display: flex; gap: 0.25rem; padding: 0; margin: 0; } .note { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #64748b; margin-top: 1rem; line-height: 1.5; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## References - [@layer - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@layer) - [Cascade layers - MDN Learn](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Cascade_layers) - [Cascade Layers Guide - CSS-Tricks](https://css-tricks.com/css-cascade-layers/) - [revert-layer - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/revert-layer) - [Hello, CSS Cascade Layers - Ahmad Shadeed](https://ishadeed.com/article/cascade-layers/) --- # Component Tokens & Arbitrary Values > Source: https://takazudomodular.com/pj/zcss/docs/methodology/design-systems/tight-token-strategy/component-tokens ## The Problem When building components with a tight token strategy, teams encounter a recurring question: **"The value I need isn't in our token set — should I add a new token?"** This question arises constantly. A button needs to be exactly 28px wide for its icon. A grid layout requires `120px` and `1fr` columns. A decorative gradient uses a specific red that doesn't match any brand color. If the answer is always "add a token," the token set grows until it's no longer tight — you're back to the unconstrained mess the strategy was designed to prevent. But if the answer is always "use only existing tokens," developers are forced to use poorly fitting values, creating visually awkward components. Neither extreme works. The missing piece is a clear framework for deciding when a value belongs in the system and when it belongs in the component. ## The Solution The tight token strategy is built on a **component-centric philosophy**. This aligns closely with Tailwind CSS's own approach: 1. **Design tokens define the system vocabulary** — spacing, colors, typography scales that ensure consistency across all components 2. **Components are built using those tokens** — for all standard, reusable spacing and colors 3. **But not everything fits into the system** — component-specific details use arbitrary values The key insight: **adding a token is a system-level decision, not a component-level one.** When a particular value is unique to a single component's layout or decoration, it should stay as an arbitrary value (Tailwind's bracket syntax like `w-[28px]`), not be promoted to the token set. ### When to Use Arbitrary Values Use Tailwind's bracket syntax for values that are structural details of individual components, not system-wide patterns: - **Component-specific sizing** — A small icon button that needs exactly `w-[28px] p-[6px]` for visual balance - **Grid template columns** — Layouts like `grid-cols-[120px_1fr]` that define one component's structure - **Unique icon dimensions** — An icon sized to `w-[18px]` for optical alignment within its context - **One-off decorative values** — A gradient `from-[hsl(0_60%_20%)] to-[hsl(0_60%_8%)]` used only in one hero section - **Mathematically calculated values** — Offsets like `top-[calc(100%-2px)]` for precise positioning ### When to Use System Tokens Use tokens from the project's `@theme` definition for values that represent shared design decisions: - **Standard component spacing** — `px-hsp-sm py-vsp-xs` for card padding, section margins - **Colors with semantic meaning** — `bg-primary text-text-muted` for brand and text - **Gaps between repeated elements** — `gap-x-hsp-xs gap-y-vsp-sm` for grids and flex layouts - **Any value referenced by name in design specs** — If a designer says "use section gap," that's a token ## Decision Framework When deciding between a token and an arbitrary value, use this decision tree: | Situation | Action | | --- | --- | | Value represents a design decision (e.g., "section gap", "icon size") | Add a system token | | Value is a structural detail of one component (e.g., grid columns, button padding) | Use arbitrary value | | Value is mathematically calculated or decorative | Use arbitrary value | Whether to create a token is an **architectural and design judgment**, not a usage-count threshold. You don't wait for three components to use a value before tokenizing it — you ask whether the value represents a deliberate design decision. A content column width of 800px is worth tokenizing the moment you decide "our layout is 800px wide," even if only one page template uses it today. This is the same kind of judgment as "should we extract this function into a utility?" — the answer depends on whether the concept deserves a name in the system, not on how many call sites exist. ### Signs You Should Add a Token - A designer references the value as a named step (a spacing level, an icon size, a layout width) - The value represents a design decision — a deliberate choice about how the UI is structured - Changing this value should update the entire system, not one component ### Signs You Should NOT Add a Token - It's a structural or layout detail specific to one component's internal arrangement - Adding it would clutter the token set without adding clarity - The value has no meaning outside its component context (e.g., a `calc()` offset for alignment) ## Component-First Projects In projects that combine a component framework (React, Vue, Svelte) with Tailwind CSS, component-tier CSS custom properties are unnecessary. The component architecture itself provides scoping — each component's template contains its styling directly: ```html ``` There is no separate CSS file where `--card-sidebar-width: 80px` or `--card-avatar-size: 64px` would be defined. The arbitrary values live in the JSX as bracket syntax, and the component file boundary provides all the scoping needed. Component-scoped CSS custom properties become relevant in **general CSS approaches** — BEM, CSS Modules, or any architecture where components have dedicated CSS files separate from their markup. The guidance about component-level variables in this article applies specifically to those contexts. ## Naming Convention for Component-Scoped Variables When defining component-scoped CSS custom properties in general CSS, use an underscore prefix to signal local scope: ```css .accordion { --_accordion-max-height: 200px; --_accordion-speed: 300ms; max-height: var(--_accordion-max-height); transition: max-height var(--_accordion-speed); } .dialog { --_dialog-side-spacing: 24px; padding-inline: var(--_dialog-side-spacing); } ``` The leading underscore (`--_`) tells readers "this variable is locally scoped to this component." It distinguishes component-level variables from global theme tokens like `--color-primary` or `--spacing-sm`. Both single underscore (`--_`) and double underscore (`--__`) conventions exist. Choose one and apply it consistently across the project. The single underscore (`--_`) is more common. ```css /* Single underscore — more common */ .menu { --_menu-gap: 8px; } /* Double underscore — also valid */ .menu { --__menu-gap: 8px; } ``` ## Demos ### System Tokens Mixed with Arbitrary Values This card component uses system tokens for its standard spacing and colors, but arbitrary values for its component-specific grid layout and decorative icon sizing. Comments show which values come from the token system and which are arbitrary. Project Dashboard Overview Analytics Settings 2,847 Active users 94.2% Uptime System tokens: padding, gaps, colors Arbitrary values: icon size, grid columns, stat font `} css={`.card-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } /* ── Header: system tokens for padding, arbitrary for icon ── */ .card-demo__header { display: flex; align-items: center; gap: 12px; /* hsp-xs (system token) */ padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); } .card-demo__icon { width: 18px; /* ARBITRARY: icon-specific size */ height: 18px; /* ARBITRARY: icon-specific size */ color: hsl(221 83% 53%); flex-shrink: 0; } .card-demo__icon svg { width: 100%; height: 100%; } .card-demo__title { font-weight: 700; font-size: 15px; } /* ── Body: system tokens for padding ── */ .card-demo__body { padding: 20px; /* vsp-sm / hsp-sm (system tokens) */ } /* ── Grid: ARBITRARY column template ── */ .card-demo__grid { display: grid; grid-template-columns: 120px 1fr; /* ARBITRARY: component layout */ gap: 20px; /* hsp-sm (system token) */ } /* ── Sidebar nav ── */ .card-demo__sidebar { display: flex; flex-direction: column; gap: 4px; /* vsp-2xs (system token) */ } .card-demo__nav-item { padding: 4px 12px; /* vsp-2xs / hsp-xs (system tokens) */ border-radius: 4px; font-size: 13px; color: hsl(215 16% 47%); cursor: default; } .card-demo__nav-item--active { background: hsl(221 83% 53%); color: hsl(0 0% 100%); } /* ── Stats ── */ .card-demo__content { display: flex; flex-direction: column; gap: 8px; /* vsp-xs (system token) */ } .card-demo__stat { padding: 8px 12px; /* vsp-xs / hsp-xs (system tokens) */ background: hsl(210 40% 96%); border-radius: 4px; } .card-demo__stat-value { font-size: 22px; /* ARBITRARY: decorative display size */ font-weight: 700; line-height: 1.2; color: hsl(222 47% 11%); } .card-demo__stat-label { font-size: 12px; color: hsl(215 16% 47%); } /* ── Annotations ── */ .card-demo__annotations { display: flex; gap: 20px; /* hsp-sm */ padding: 8px 20px; /* vsp-xs / hsp-sm */ border-top: 1px solid hsl(214 32% 91%); background: hsl(210 40% 96%); font-size: 12px; color: hsl(215 16% 47%); } .card-demo__annotation { display: flex; align-items: center; gap: 6px; } .card-demo__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .card-demo__dot--token { background: hsl(142 71% 45%); } .card-demo__dot--arbitrary { background: hsl(33 95% 54%); }`} height={320} /> In the code, system tokens appear where the value follows the project's spacing vocabulary — `hsp-sm` (20px) for horizontal padding, `vsp-xs` (8px) for vertical padding, `hsp-xs` (12px) for gaps. Arbitrary values appear for component-specific details: the icon is `18px` for optical balance, the grid uses `120px` for its sidebar width, and the stat number uses `22px` for visual impact. None of these arbitrary values belong in the system token set because they're meaningful only inside this component. ### Token Promotion: Before and After When the same arbitrary value keeps appearing across components, it's a signal to promote it to a system token. This demo shows a pricing layout where a frequently-used card width has been promoted from an arbitrary value to a named token. Before: arbitrary values everywhere Basic $9 For individuals Pro $29 For teams Enterprise $99 For organizations gap: 15px; padding: 18px 22px; font-size: 28px; Each dev picked different values After: promoted to system tokens Basic $9 For individuals Pro $29 For teams Enterprise $99 For organizations gap: hsp-sm; padding: vsp-sm hsp-sm; font-size: heading; Consistent tokens across all cards `} css={`.promo-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 24px; padding: 16px; } .promo-demo__section { display: flex; flex-direction: column; gap: 8px; } .promo-demo__label { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } /* ── Card grids ── */ .promo-demo__cards { display: flex; gap: 12px; } .promo-demo__card { flex: 1; border-radius: 8px; text-align: center; border: 1px solid hsl(214 32% 91%); } /* Before: inconsistent arbitrary padding */ .promo-demo__card--before:nth-child(1) { padding: 18px 22px; /* dev A */ } .promo-demo__card--before:nth-child(2) { padding: 14px 20px; /* dev B */ } .promo-demo__card--before:nth-child(3) { padding: 16px 24px; /* dev C */ } /* After: consistent system tokens */ .promo-demo__card--after { padding: 20px 20px; /* vsp-sm / hsp-sm — system tokens */ } .promo-demo__card-name { font-weight: 700; font-size: 13px; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; } /* Before: inconsistent font sizes */ .promo-demo__card-price--before { font-weight: 700; line-height: 1.2; } .promo-demo__card--before:nth-child(1) .promo-demo__card-price--before { font-size: 28px; /* dev A */ } .promo-demo__card--before:nth-child(2) .promo-demo__card-price--before { font-size: 32px; /* dev B */ } .promo-demo__card--before:nth-child(3) .promo-demo__card-price--before { font-size: 26px; /* dev C */ } /* After: consistent token-based size */ .promo-demo__card-price--after { font-size: 28px; /* heading token (1.75rem = 28px) */ font-weight: 700; line-height: 1.2; } .promo-demo__card-desc { font-size: 12px; color: hsl(215 16% 47%); margin-top: 4px; } /* ── Code callouts ── */ .promo-demo__code { padding: 8px 12px; background: hsl(210 40% 96%); border-radius: 4px; font-size: 12px; } .promo-demo__code code { font-family: ui-monospace, monospace; color: hsl(222 47% 11%); } .promo-demo__code-note { color: hsl(215 16% 47%); margin-top: 2px; font-size: 11px; }`} height={420} /> In the "before" version, three developers each picked slightly different padding and font sizes for the same pricing card component. The cards look subtly uneven — `18px` vs `14px` vs `16px` vertical padding, `28px` vs `32px` vs `26px` price font size. Once the team noticed this pattern recurring across pricing, feature cards, and testimonial cards, they promoted the values to system tokens: `vsp-sm` / `hsp-sm` for padding and `heading` for the price font size. Now every card automatically stays consistent. ### Mixing System Tokens and Arbitrary Values in a Real Layout This demo simulates a realistic component from a production project. System tokens handle all the standard spacing, colors, and typography. Arbitrary values handle the component-specific layout grid and decorative details. Section with system token spacing Jane Smith Admin Active Premium Email jane@example.com Joined Mar 2024 View Profile `} css={`.prod-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } /* ── Section: system token padding ── */ .prod-demo__section { padding: 20px; /* vsp-sm / hsp-sm */ } .prod-demo__section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); margin-bottom: 8px; /* vsp-xs */ } /* ── Grid: ARBITRARY column template ── */ .prod-demo__grid { display: grid; grid-template-columns: 80px 1fr; /* ARBITRARY: profile layout */ gap: 20px; /* hsp-sm (system token) */ } /* ── Sidebar: ARBITRARY avatar size ── */ .prod-demo__sidebar { text-align: center; } .prod-demo__avatar { width: 64px; /* ARBITRARY: avatar specific */ height: 64px; /* ARBITRARY: avatar specific */ border-radius: 50%; background: linear-gradient( 135deg, hsl(221 83% 53%), hsl(250 80% 68%) ); margin: 0 auto 8px; /* vsp-xs */ } .prod-demo__user-name { font-weight: 700; font-size: 13px; } .prod-demo__user-role { font-size: 11px; color: hsl(215 16% 47%); } /* ── Tags: system token padding ── */ .prod-demo__row { display: flex; gap: 8px; /* vsp-xs */ margin-bottom: 8px; /* vsp-xs */ } .prod-demo__tag { padding: 4px 12px; /* vsp-2xs / hsp-xs (system tokens) */ border-radius: 4px; font-size: 12px; font-weight: 500; } .prod-demo__tag--success { background: hsl(142 71% 92%); color: hsl(142 71% 30%); } .prod-demo__tag--info { background: hsl(221 83% 92%); color: hsl(221 83% 40%); } /* ── Detail rows ── */ .prod-demo__details { display: flex; flex-direction: column; gap: 4px; /* vsp-2xs */ } .prod-demo__detail-row { display: flex; gap: 12px; /* hsp-xs */ font-size: 13px; } .prod-demo__detail-label { color: hsl(215 16% 47%); min-width: 50px; } .prod-demo__detail-value { color: hsl(222 47% 11%); } /* ── Footer: system token padding, ARBITRARY button sizing ── */ .prod-demo__footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; /* vsp-xs */ padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ border-top: 1px solid hsl(214 32% 91%); background: hsl(210 40% 96%); } .prod-demo__btn { border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; } .prod-demo__btn--icon { width: 32px; /* ARBITRARY: icon button size */ height: 32px; /* ARBITRARY: icon button size */ padding: 6px; /* ARBITRARY: icon inner spacing */ background: hsl(210 40% 96%); color: hsl(215 16% 47%); display: flex; align-items: center; justify-content: center; } .prod-demo__btn-svg { width: 16px; /* ARBITRARY: icon size */ height: 16px; /* ARBITRARY: icon size */ } .prod-demo__btn--primary { padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ background: hsl(221 83% 53%); color: hsl(0 0% 100%); }`} height={280} /> In a real Tailwind project, this component's classes would look like: ```html Active Edit View Profile ``` ## Common AI Mistakes ### Mistake 1: Creating Tokens for Every Value ```html ``` One-off values clutter the token set. A `64px` avatar size is meaningful only within the profile component. If it were promoted to a token, other developers might use it for unrelated purposes, diluting its intent. ### Mistake 2: Using Arbitrary Values for Everything ```html ``` If a value exists in the token set, always use the token. Arbitrary values should only appear when the token set genuinely doesn't cover the need. ### Mistake 3: Adding Tokens Preemptively ```html ``` The distinction is not about usage count — it's about whether the value represents a **design decision** or a **structural detail**. If the design says "icons are 18px," that's a token from day one, even if only one component uses it. If 18px is just the padding that makes one specific button look balanced, it stays arbitrary forever. This is the same judgment as "should we extract this function to a utility?" in application architecture. The answer depends on whether the concept deserves a name in the system, not on how many call sites exist. ## When to Use This component-centric approach applies whenever you work with the tight token strategy: - **Building new components** — Default to system tokens for spacing and colors, use arbitrary for layout-specific details like grid columns and decorative values - **Reviewing component code** — Check whether arbitrary values represent design decisions that should be tokens, or structural details that should stay local - **Defining the token set** — Tokens come from design decisions (icon sizes, layout widths, avatar dimensions), not from counting how many components share a value - **Refactoring existing components** — Look for hardcoded values that match existing tokens and replace them The goal is a token set that stays small and meaningful. Every token should earn its place by representing a genuine design decision. Everything else stays at the component level as an arbitrary value. For a detailed look at how this applies specifically to width/height sizing — where the abstract scale layer is intentionally skipped — see [Two-Tier Size Strategy](../../two-tier-size-strategy/). --- # Media Query Best Practices > Source: https://takazudomodular.com/pj/zcss/docs/responsive/media-query-best-practices ## The Problem AI agents use media queries as the default (and often only) responsive tool. They frequently use arbitrary device-based breakpoints, ignore user preference queries (`prefers-reduced-motion`, `prefers-color-scheme`, `prefers-contrast`), and never use feature queries (`@supports`). They also tend to write desktop-first styles and then override everything for mobile, leading to bloated CSS. ## The Solution Media queries should be one tool among many for responsive design. Use them for page-level layout changes and user preference detection. Prefer content-driven breakpoints over device-specific ones, and adopt a mobile-first approach. Header Navigation Main Content Area Sidebar Footer `} css={` /* Mobile-first: base styles for small screens */ .layout { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem; min-height: 300px; } .layout__header, .layout__nav, .layout__main, .layout__sidebar, .layout__footer { padding: 1rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; display: flex; align-items: center; justify-content: center; } .layout__header { background: #3b82f6; color: white; } .layout__nav { background: #8b5cf6; color: white; } .layout__main { background: #22c55e; color: white; flex: 1; min-height: 100px; } .layout__sidebar { background: #f59e0b; color: white; } .layout__footer { background: #64748b; color: white; } /* Tablet: two-column layout */ @media (min-width: 600px) { .layout { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto 1fr auto; } .layout__header { grid-column: 1 / -1; } .layout__nav { grid-column: 1 / -1; } .layout__footer { grid-column: 1 / -1; } } /* Desktop: sidebar layout */ @media (min-width: 900px) { .layout { grid-template-columns: 1fr 200px; grid-template-rows: auto auto 1fr auto; } .layout__header { grid-column: 1 / -1; } .layout__nav { grid-column: 1 / -1; } .layout__main { grid-column: 1; } .layout__sidebar { grid-column: 2; } .layout__footer { grid-column: 1 / -1; } } `} height={350} /> ## Code Examples ### Mobile-First vs. Desktop-First Mobile-first uses `min-width` queries, starting from the smallest screen and adding complexity: ```css /* Mobile-first: base styles are for small screens */ .layout { display: flex; flex-direction: column; gap: 1rem; } @media (min-width: 48rem) { .layout { flex-direction: row; } } @media (min-width: 64rem) { .layout { max-width: 75rem; margin-inline: auto; } } ``` Desktop-first uses `max-width` queries, starting from the largest screen and removing features: ```css /* Desktop-first: more overrides needed */ .layout { display: flex; flex-direction: row; max-width: 75rem; margin-inline: auto; } @media (max-width: 63.999rem) { .layout { max-width: none; } } @media (max-width: 47.999rem) { .layout { flex-direction: column; } } ``` Mobile-first results in less CSS overall because you add styles as the viewport grows rather than removing them. ### Content-Driven Breakpoints Instead of targeting specific devices, add breakpoints where your layout breaks: ```css /* Let the content dictate the breakpoint */ .article { max-width: 65ch; /* Optimal reading width */ margin-inline: auto; padding-inline: 1rem; } .article-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; } /* Add a second column when there is enough room */ @media (min-width: 55rem) { .article-grid { grid-template-columns: 1fr 20rem; } } ``` ### User Preference: prefers-color-scheme ```css :root { --color-text: #1a1a1a; --color-bg: #ffffff; --color-surface: #f5f5f5; --color-border: #e0e0e0; } @media (prefers-color-scheme: dark) { :root { --color-text: #e0e0e0; --color-bg: #1a1a1a; --color-surface: #2a2a2a; --color-border: #3a3a3a; } } body { color: var(--color-text); background-color: var(--color-bg); } ``` Allow manual override with a data attribute: ```css [data-theme="light"] { --color-text: #1a1a1a; --color-bg: #ffffff; --color-surface: #f5f5f5; --color-border: #e0e0e0; } [data-theme="dark"] { --color-text: #e0e0e0; --color-bg: #1a1a1a; --color-surface: #2a2a2a; --color-border: #3a3a3a; } ``` ### User Preference: prefers-reduced-motion ```css /* Remove transitions and animations for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } ``` See the dedicated [prefers-reduced-motion](../interactive/forms-and-accessibility/prefers-reduced-motion.mdx) page for a more nuanced approach. ### User Preference: prefers-contrast ```css @media (prefers-contrast: more) { :root { --color-text: #000000; --color-bg: #ffffff; --color-border: #000000; } .button { border: 2px solid currentColor; } } @media (prefers-contrast: less) { :root { --color-text: #333333; --color-bg: #fafafa; --color-border: #cccccc; } } ``` ### Interaction Media Queries: hover and pointer ```css /* Only apply hover styles on devices that support hover */ @media (hover: hover) { .card { transition: box-shadow 0.2s ease; } .card:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.15); } } /* Increase touch targets on coarse pointer devices */ @media (pointer: coarse) { .nav-link { min-height: 44px; padding-block: 0.75rem; } } ``` ### Feature Queries with @supports ```css /* Base layout */ .grid { display: flex; flex-wrap: wrap; gap: 1rem; } .grid > * { flex: 1 1 300px; } /* Enhanced layout for browsers with grid subgrid support */ @supports (grid-template-columns: subgrid) { .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } .grid > * { display: grid; grid-template-rows: subgrid; grid-row: span 3; } } ``` ### Combining Queries ```css /* Dark mode + reduced motion */ @media (prefers-color-scheme: dark) and (prefers-reduced-motion: reduce) { .notification { background-color: var(--color-surface); /* No entrance animation, just appear */ } } ``` ## Common AI Mistakes - **Device-specific breakpoints**: Using `@media (max-width: 768px)` because "that's the iPad width." Breakpoints should be driven by content, not device catalogs. - **Desktop-first approach**: Writing full desktop styles first and then stripping them away for mobile, creating unnecessary overrides. - **Ignoring user preferences**: Never including `prefers-color-scheme`, `prefers-reduced-motion`, or `prefers-contrast` queries. - **Using media queries for component layouts**: Using `@media` when `@container` would be more appropriate. Media queries are for page-level layout; container queries are for component-level adaptation. - **Missing `@media (hover: hover)`**: Adding `:hover` styles that create sticky hover states on touch devices. - **Not using `@supports`**: Writing modern CSS features without fallbacks and without checking support. - **Using `px` for breakpoints**: Pixel breakpoints do not scale with user font-size preferences. Use `rem` values (e.g., `48rem` instead of `768px`). - **Too many breakpoints**: Creating 5+ breakpoints when `clamp()` or intrinsic sizing would handle the fluid range. ## When to Use - **Page-level layout changes**: Switching between single-column and multi-column page layouts. - **User preference detection**: `prefers-color-scheme`, `prefers-reduced-motion`, `prefers-contrast`. - **Input modality adaptation**: `hover`, `pointer` for adjusting interactions to input type. - **Feature detection**: `@supports` for progressive enhancement with new CSS features. - **Not for component layouts**: Use container queries instead. - **Not for fluid sizing**: Use `clamp()` instead of breakpoint jumps. ## References - [Using Media Queries — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using) - [@media — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media) - [CSS Media Queries Complete Guide for 2026 — DevToolbox](https://devtoolbox.dedyn.io/blog/css-media-queries-complete-guide) - [Solving Sticky Hover States with @media (hover: hover) — CSS-Tricks](https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/) --- # Dark Mode Strategies > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/dark-mode-strategies ## The Problem AI agents frequently implement dark mode by duplicating entire stylesheets or by using JavaScript to toggle classes that override every color declaration. The resulting code is verbose, fragile, and difficult to maintain. Common mistakes include inverting colors naively (white becomes black, brand colors stay the same), not adjusting perceived brightness of text and surfaces, and creating harsh contrast that causes eye strain in dark mode. ## The Solution Modern CSS provides a layered approach to dark mode: 1. **`color-scheme`** — Tells the browser to adjust UA-styled elements (form controls, scrollbars) to light or dark 2. **`prefers-color-scheme`** — A media query that detects the user's OS-level theme preference 3. **`light-dark()`** — A CSS function (Baseline 2024) that returns one of two color values depending on the active color scheme 4. **CSS custom properties** — The backbone for theming, allowing a single set of property declarations to swap color tokens ## Code Examples ### color-scheme: Opt Into Browser Dark Mode ```css /* Tell the browser this page supports both light and dark */ :root { color-scheme: light dark; } ``` This single line makes form controls, scrollbars, and other browser-styled elements automatically adapt. Without it, ``, ``, and `` remain light-themed even when the page background is dark. ### prefers-color-scheme Media Query ```css :root { --color-bg: oklch(99% 0.005 264); --color-surface: oklch(97% 0.01 264); --color-text: oklch(20% 0.02 264); --color-text-muted: oklch(40% 0.02 264); --color-border: oklch(85% 0.01 264); --color-primary: oklch(55% 0.22 264); } @media (prefers-color-scheme: dark) { :root { --color-bg: oklch(15% 0.01 264); --color-surface: oklch(20% 0.015 264); --color-text: oklch(90% 0.01 264); --color-text-muted: oklch(65% 0.01 264); --color-border: oklch(30% 0.015 264); --color-primary: oklch(70% 0.18 264); /* Lighter primary for dark bg */ } } ``` ### The light-dark() Function `light-dark()` simplifies dark mode by inlining both color values in a single declaration. It requires `color-scheme` to be set. ```css :root { color-scheme: light dark; --color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264)); --color-surface: light-dark(oklch(97% 0.01 264), oklch(20% 0.015 264)); --color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264)); --color-text-muted: light-dark(oklch(40% 0.02 264), oklch(65% 0.01 264)); --color-border: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264)); --color-primary: light-dark(oklch(55% 0.22 264), oklch(70% 0.18 264)); } ``` The first argument is used in light mode, the second in dark mode. No media query needed. Light Theme Card Title Body text on a light surface. The primary color is adjusted for sufficient contrast on the light background. Primary Action Muted helper text Dark Theme Card Title Body text on a dark surface. Brand colors are lighter and less saturated to reduce eye strain and maintain contrast. Primary Action Muted helper text `} css={`.theme-demo { display: grid; grid-template-columns: 1fr 1fr; font-family: system-ui, sans-serif; } .panel { padding: 1.5rem; } .panel h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 1rem; font-weight: 600; } .light-theme { --bg: oklch(99% 0.005 264); --surface: oklch(97% 0.01 264); --text: oklch(20% 0.02 264); --text-muted: oklch(45% 0.02 264); --border: oklch(85% 0.01 264); --primary: oklch(55% 0.22 264); --primary-text: white; background: var(--bg); color: var(--text); } .dark-theme { --bg: oklch(15% 0.01 264); --surface: oklch(20% 0.015 264); --text: oklch(90% 0.01 264); --text-muted: oklch(60% 0.015 264); --border: oklch(30% 0.015 264); --primary: oklch(70% 0.18 264); --primary-text: oklch(15% 0.01 264); background: var(--bg); color: var(--text); } .light-theme h3 { color: oklch(50% 0.15 264); } .dark-theme h3 { color: oklch(70% 0.12 264); } .component { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; } .component h4 { margin: 0 0 0.5rem; font-size: 1.1rem; } .component p { margin: 0 0 1rem; font-size: 0.9rem; line-height: 1.5; } .btn { background: var(--primary); color: var(--primary-text); border: none; padding: 0.5rem 1.25rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer; margin-right: 0.75rem; } .muted { font-size: 0.8rem; color: var(--text-muted); }`} height={300} /> ### JavaScript Theme Toggle For user-controlled theme switching (overriding OS preference): ```html Toggle theme ``` ```css :root { color-scheme: light dark; } :root[data-theme="light"] { color-scheme: light; } :root[data-theme="dark"] { color-scheme: dark; } /* Custom properties using light-dark() respond to color-scheme */ :root { --color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264)); --color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264)); } ``` ```html const toggle = document.getElementById("theme-toggle"); const root = document.documentElement; // Check for saved preference, fallback to OS preference const saved = localStorage.getItem("theme"); if (saved) { root.dataset.theme = saved; } toggle.addEventListener("click", () => { const current = root.dataset.theme; const next = current === "dark" ? "light" : current === "light" ? "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches ? "light" : "dark"; root.dataset.theme = next; localStorage.setItem("theme", next); }); ``` ### Complete Dark Mode Token System ```css :root { color-scheme: light dark; /* Surfaces */ --surface-0: light-dark(oklch(100% 0 0), oklch(13% 0.01 264)); --surface-1: light-dark(oklch(97% 0.005 264), oklch(18% 0.012 264)); --surface-2: light-dark(oklch(94% 0.008 264), oklch(22% 0.015 264)); --surface-3: light-dark(oklch(90% 0.01 264), oklch(27% 0.018 264)); /* Text */ --text-primary: light-dark(oklch(20% 0.02 264), oklch(92% 0.01 264)); --text-secondary: light-dark(oklch(40% 0.015 264), oklch(70% 0.01 264)); --text-disabled: light-dark(oklch(60% 0.01 264), oklch(45% 0.01 264)); /* Borders */ --border-default: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264)); --border-strong: light-dark(oklch(70% 0.015 264), oklch(45% 0.02 264)); /* Brand */ --brand: light-dark(oklch(55% 0.22 264), oklch(72% 0.17 264)); --brand-hover: light-dark(oklch(48% 0.22 264), oklch(78% 0.15 264)); /* Feedback */ --success: light-dark(oklch(48% 0.15 145), oklch(70% 0.15 145)); --warning: light-dark(oklch(58% 0.18 85), oklch(75% 0.15 85)); --danger: light-dark(oklch(52% 0.2 25), oklch(70% 0.18 25)); } ``` ### Dark Mode for Images and Media ```css /* Reduce brightness and increase contrast for images in dark mode */ @media (prefers-color-scheme: dark) { img:not([src*=".svg"]) { filter: brightness(0.9) contrast(1.05); } /* Invert dark-on-light diagrams and illustrations */ img.invertible { filter: invert(1) hue-rotate(180deg); } } ``` ### Preventing Flash of Wrong Theme (FOWT) ```html (function () { const saved = localStorage.getItem("theme"); if (saved) { document.documentElement.dataset.theme = saved; } })(); ``` ## Common AI Mistakes - Not setting `color-scheme: light dark` on `:root`, causing form controls and scrollbars to remain in light mode even when the page is dark - Duplicating entire stylesheets for dark mode instead of swapping CSS custom properties - Using `light-dark()` without declaring `color-scheme` — the function returns the first (light) value by default if `color-scheme` is not set - Inverting colors naively (`white` ↔ `black`) instead of adjusting lightness levels — dark mode backgrounds should be dark gray (not pure black) and text should be off-white (not pure white) - Keeping the same brand color in both modes — saturated colors on dark backgrounds appear overly vibrant and need reduced chroma and increased lightness - Not reducing font weight in dark mode — text on dark backgrounds appears perceptually heavier, so reducing `font-weight` by 30–50 units improves readability - Applying `filter: invert(1)` to the entire page as a "dark mode" — this breaks images, videos, and any element with intentional colors - Storing theme preference in JavaScript state instead of `localStorage`, causing a flash of wrong theme on page reload - Using JavaScript to toggle `.dark-mode` classes on individual elements instead of leveraging custom properties on `:root` ## When to Use ### prefers-color-scheme - The simplest approach when the site should respect OS preferences with no manual toggle - Static sites, blogs, documentation ### light-dark() - When you want both color values co-located in the same declaration for readability - When using `color-scheme` (on `:root` or specific elements) to control mode ### Custom properties + data attribute - When users need a manual theme toggle - When the app supports more than two themes (light, dark, high-contrast, etc.) - SPAs and web applications - For organizing these tokens into palette, theme, and component layers, see [Three-Tier Color Strategy](../three-tier-color-strategy) ### color-scheme alone - For pages that only need browser-native element theming (forms, scrollbars) without custom color changes ## Tailwind CSS Tailwind's `dark:` variant makes dark mode styling straightforward. With the `class` strategy, adding a `dark` class to a parent element activates all `dark:` utilities within it. tailwind.config = { darkMode: 'class' } Light Mode Card Title Body text on a light surface with appropriate contrast. Action Muted text Dark Mode Card Title The same markup with dark: variants applied. Action Muted text `} height={280} /> ## References - [MDN: prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme) - [MDN: light-dark()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark) - [MDN: color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme) - [CSS color-scheme-dependent colors with light-dark() — web.dev](https://web.dev/articles/light-dark) - [Dark Mode in CSS Guide — CSS-Tricks](https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/) - [The ultimate guide to coding dark mode layouts in 2025 — Bootcamp](https://medium.com/design-bootcamp/the-ultimate-guide-to-implementing-dark-mode-in-2025-bbf2938d2526) --- # Filter Effects > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/filter-effects ## The Problem AI agents underuse CSS filters, often resorting to image editing tools or JavaScript for effects that CSS `filter` handles natively. When filters are used, agents confuse `filter: drop-shadow()` with `box-shadow`, apply `blur()` to elements when they mean `backdrop-filter: blur()`, and forget that multiple filter functions can be chained in a single declaration. The critical difference between `drop-shadow()` and `box-shadow` — shape-awareness — is almost never considered. ## The Solution The CSS `filter` property applies graphical effects to an element and all its contents. It accepts a space-separated list of filter functions applied in order. Key functions include `blur()`, `brightness()`, `contrast()`, `saturate()`, `grayscale()`, `sepia()`, `hue-rotate()`, `invert()`, `opacity()`, and `drop-shadow()`. The most important distinction is between `filter` (affects the element itself) and `backdrop-filter` (affects what is behind the element). Within `filter`, `drop-shadow()` follows the shape of the element's alpha channel while `box-shadow` always renders as a rectangle. ## Code Examples ### Individual Filter Functions ```css /* Blur */ .blurred { filter: blur(4px); } /* Brightness — 1 is normal, >1 is brighter, <1 is darker */ .bright { filter: brightness(1.3); } .dimmed { filter: brightness(0.6); } /* Contrast — 1 is normal, >1 is more contrast */ .high-contrast { filter: contrast(1.5); } /* Saturate — 1 is normal, 0 is grayscale, >1 is oversaturated */ .vivid { filter: saturate(1.8); } .desaturated { filter: saturate(0.3); } /* Grayscale — 0 is normal, 1 is fully gray */ .gray { filter: grayscale(1); } /* Sepia — vintage photo tint */ .vintage { filter: sepia(0.8); } /* Hue-Rotate — shifts all colors around the color wheel */ .hue-shifted { filter: hue-rotate(90deg); } /* Invert — negative image */ .inverted { filter: invert(1); } ``` ### Chaining Multiple Filters Filters are applied left to right. Order matters — `brightness` before `contrast` produces different results than `contrast` before `brightness`. ```css /* Vibrant, slightly warm look */ .photo-enhance { filter: contrast(1.1) saturate(1.3) brightness(1.05); } /* Muted vintage effect */ .photo-vintage { filter: sepia(0.4) contrast(0.9) brightness(1.1) saturate(0.8); } /* Dramatic noir */ .photo-noir { filter: grayscale(1) contrast(1.4) brightness(0.9); } ``` ### drop-shadow() vs box-shadow `box-shadow` renders a rectangular shadow behind the element's bounding box. `drop-shadow()` follows the actual shape of the element, including transparent areas in PNG/SVG images. ```css /* box-shadow — rectangle behind the entire element */ .icon-box-shadow { box-shadow: 4px 4px 8px hsl(0deg 0% 0% / 0.3); } /* drop-shadow — follows the icon's shape */ .icon-drop-shadow { filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); } ``` ```html ``` The first image has a rectangular shadow. The second has a shadow that hugs the star's outline. #### drop-shadow Syntax Differences ```css /* drop-shadow does NOT support: */ /* - inset keyword */ /* - spread radius */ /* Syntax: drop-shadow(offset-x offset-y blur-radius color) */ .shadow { /* Valid */ filter: drop-shadow(2px 4px 6px hsl(0deg 0% 0% / 0.2)); /* Invalid — no spread value allowed */ /* filter: drop-shadow(2px 4px 6px 2px black); */ /* Invalid — no inset allowed */ /* filter: drop-shadow(inset 2px 4px 6px black); */ } ``` ### Hover Effects with Filters ```css .image-hover { transition: filter 0.3s ease; } /* Brighten on hover */ .image-hover:hover { filter: brightness(1.15); } ``` ```css /* Color to grayscale on idle, full color on hover */ .team-photo { filter: grayscale(1); transition: filter 0.4s ease; } .team-photo:hover { filter: grayscale(0); } ``` ```css /* Subtle zoom + brightness for image cards */ .card-image { overflow: hidden; } .card-image img { transition: filter 0.3s ease, transform 0.3s ease; } .card-image:hover img { filter: brightness(1.1) saturate(1.2); transform: scale(1.03); } ``` ### Disabled State with Filters ```css .disabled { filter: grayscale(1) opacity(0.5); pointer-events: none; } ``` ### drop-shadow on Clipped Elements `drop-shadow()` respects `clip-path`, unlike `box-shadow` which always renders as a rectangle. ```css .clipped-with-shadow { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); /* Shadow follows the diamond clip shape */ } ``` Note: The `filter` must be on the element itself. If the shadow is cut by the clip-path, wrap the element in a container and apply `filter` to the container instead. ```css /* Shadow wrapper pattern */ .shadow-wrapper { filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); } .shadow-wrapper .clipped-element { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); background: white; } ``` ```html Diamond with shadow ``` ### Dark Mode Filter Shortcut for Images ```css /* Quick dark mode adaptation for decorative images */ @media (prefers-color-scheme: dark) { .decorative-image { filter: brightness(0.85) contrast(1.1); } } ``` ## Live Previews Originalblur(4px)brightness(1.5)contrast(1.8)saturate(2)hue-rotate(90deg)`} css={` .demo { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; align-items: center; height: 100%; background: #0f172a; padding: 16px; font-family: system-ui, sans-serif; } .item { display: flex; flex-direction: column; align-items: center; gap: 6px; } .box { width: 100px; height: 80px; border-radius: 8px; background: linear-gradient(135deg, #ef4444 0%, #f59e0b 25%, #22c55e 50%, #3b82f6 75%, #8b5cf6 100%); } .blur { filter: blur(4px); } .brightness { filter: brightness(1.5); } .contrast { filter: contrast(1.8); } .saturate { filter: saturate(2); } .hue-rotate { filter: hue-rotate(90deg); } .item span { font-size: 11px; color: #94a3b8; font-family: monospace; } `} height={250} /> box-shadow (rectangular)drop-shadow (shape-aware)`} css={` .demo { display: flex; gap: 40px; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .col { display: flex; flex-direction: column; align-items: center; gap: 12px; } .triangle-wrapper { width: 120px; height: 120px; display: flex; justify-content: center; align-items: center; } .triangle { width: 0; height: 0; border-left: 50px solid transparent; border-right: 50px solid transparent; border-bottom: 86px solid #3b82f6; } .box-shadow-demo .triangle { box-shadow: 6px 6px 12px hsl(0deg 0% 0% / 0.3); } .drop-shadow-demo { filter: drop-shadow(6px 6px 12px hsl(0deg 0% 0% / 0.3)); } .col span { font-size: 12px; color: #475569; font-weight: 500; } `} height={250} /> ## Common AI Mistakes - **Using `box-shadow` on transparent images** — The shadow appears as a rectangle, ignoring the image's shape. `filter: drop-shadow()` follows the alpha channel. - **Confusing `filter: blur()` with `backdrop-filter: blur()`** — `filter: blur()` blurs the element and all its content (including text). `backdrop-filter: blur()` blurs only what is behind the element. - **Adding spread to `drop-shadow()`** — `drop-shadow()` does not support the spread parameter. AI agents copy `box-shadow` syntax directly into `drop-shadow()` and produce invalid CSS. - **Applying multiple separate `filter` declarations** — Only the last `filter` declaration wins. Chain functions in a single declaration: `filter: blur(2px) brightness(1.2)`. - **Not considering filter order** — `brightness(0.5) contrast(2)` looks different from `contrast(2) brightness(0.5)`. The order of chained functions matters. - **Using `filter: opacity()` when `opacity` property suffices** — The `filter: opacity()` function exists for chaining with other filters, but for standalone opacity, the `opacity` property is simpler and equally performant. - **Applying `filter: drop-shadow()` directly to a clipped element** — The shadow may get clipped too. Apply the filter to a wrapping container instead. ## When to Use - **Shape-aware shadows** — `drop-shadow()` for transparent PNGs, SVG icons, and `clip-path`-clipped elements - **Image treatment** — Grayscale team photos, vintage filters, brightness adjustments without image editing - **Hover effects** — Brighten, saturate, or desaturate images on interaction - **Disabled states** — `grayscale(1) opacity(0.5)` as a universal disabled appearance - **Dark mode adjustments** — Quick brightness/contrast tweaks for images in dark themes - **Filter chains** — Combine multiple effects for Instagram-like treatments directly in CSS ## Tailwind CSS Tailwind provides utility classes for common CSS filter functions: `blur-*`, `brightness-*`, `contrast-*`, `grayscale`, `sepia`, `hue-rotate-*`, and `invert`. These can be combined on a single element. ### Filter Effects Gallery Original blur-sm brightness-150 contrast-200 saturate-200 grayscale sepia hue-rotate-90 `} height={270} /> ### Hover Filter Effects Grayscale → Color Brighten on hover Disabled → Active `} height={220} /> ## References - [filter — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) - [drop-shadow() — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/drop-shadow) - [CSS Filter Effects — Can I Use](https://caniuse.com/css-filters) - [CSS Image Filters: The Ultimate Guide — DEV Community](https://dev.to/satyam_gupta_0d1ff2152dcc/css-image-filters-the-ultimate-guide-to-stunning-visual-effects-in-2025-2mc4) - [filter — CSS-Tricks](https://css-tricks.com/almanac/properties/f/filter/) --- # Line Height Best Practices > Source: https://takazudomodular.com/pj/zcss/docs/typography/font-sizing/line-height-best-practices ## The Problem Line height is one of the most misunderstood CSS properties. AI agents frequently use values with units (`line-height: 24px` or `line-height: 1.5em`), which causes inheritance problems when child elements have different font sizes. The result is text that is either too cramped or too spaced, especially in components where font sizes vary between headings, body text, and small text. ## The Solution Use unitless `line-height` values. A unitless value is inherited as a ratio, meaning each element recalculates its actual line height based on its own font size. This prevents the common bug where a parent's computed `line-height` in pixels is inherited by children with different font sizes. ### How Unitless vs Unit-Based Inheritance Works ```css /* PROBLEMATIC: unit-based line-height */ .parent { font-size: 16px; line-height: 24px; /* computed: 24px */ } .parent h2 { font-size: 32px; /* Inherits line-height: 24px — text overlaps! */ } /* CORRECT: unitless line-height */ .parent { font-size: 16px; line-height: 1.5; /* computed: 24px (16 × 1.5) */ } .parent h2 { font-size: 32px; /* Inherits line-height ratio 1.5 → computed: 48px (32 × 1.5) */ } ``` ## Code Examples ### Recommended Values by Element Type ```css :root { /* Base line-height for body text */ line-height: 1.5; } /* Body text: 1.5 to 1.6 for optimal readability */ p, li, dd, blockquote { line-height: 1.5; } /* Headings: tighter line-height since large text needs less leading */ h1 { line-height: 1.1; } h2 { line-height: 1.2; } h3 { line-height: 1.3; } h4, h5, h6 { line-height: 1.4; } /* Small text / captions: slightly more line-height for readability */ small, .caption, .footnote { line-height: 1.6; } /* Code blocks: tighter to keep code compact */ pre, code { line-height: 1.4; } ``` line-height: 1.2 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. line-height: 1.5 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. line-height: 2.0 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. `} css={`.lh-demo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .lh-column { background: #f8f9fa; border-radius: 8px; padding: 1rem; } .lh-column h3 { font-size: 0.85rem; color: #6c63ff; margin: 0 0 0.75rem; font-weight: 600; } .lh-column p { font-size: 0.95rem; color: #333; margin: 0; } .lh-tight { line-height: 1.2; } .lh-normal { line-height: 1.5; } .lh-loose { line-height: 2.0; }`} height={320} /> ### Line Height with Fluid Typography ```css :root { --line-height-tight: 1.1; --line-height-snug: 1.3; --line-height-normal: 1.5; --line-height-relaxed: 1.6; --line-height-loose: 1.8; } h1 { font-size: clamp(2rem, 1.5rem + 2.5vw, 3.5rem); line-height: var(--line-height-tight); } p { font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); line-height: var(--line-height-normal); } ``` ### Using the `lh` Unit for Spacing The `lh` unit represents the computed `line-height` of the element, enabling spacing that aligns with the text rhythm. ```css p { line-height: 1.5; margin-block-end: 1lh; /* Margin equals one line of text */ } blockquote { line-height: 1.5; padding-block: 0.5lh; /* Half a line of padding */ border-inline-start: 0.125lh solid currentColor; } ``` ## Common AI Mistakes - Using `line-height: 24px` or `line-height: 1.5em` instead of the unitless `1.5`, causing inheritance bugs - Applying the same `line-height` to headings and body text — headings need tighter values (1.1 to 1.3) - Setting `line-height: 1` (or the `normal` keyword) for body text, which is too tight for readability. The `normal` keyword typically resolves to around 1.2 depending on the font, which is below the WCAG recommendation - Using `line-height: 2` or higher for body text, which creates excessive spacing and makes paragraphs look disconnected - Forgetting that `line-height` affects the clickable area of inline elements and links - Not accounting for the font's built-in metrics — some fonts (especially decorative ones) need different line-height values than standard sans-serif fonts ## When to Use Every text element should have an intentional `line-height` value. The general guidelines are: - **Body text (paragraphs, lists):** 1.5 to 1.6 — this meets WCAG 1.4.12 (Text Spacing) requirements - **Headings:** 1.1 to 1.3 — large text reads well with tighter leading - **Display / hero text:** 1.0 to 1.15 — very large text can be even tighter - **Small text / captions:** 1.5 to 1.7 — small text benefits from extra breathing room - **UI elements (buttons, badges):** 1 to 1.2 — where vertical centering matters more than reading flow ## References - [MDN: line-height](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/line-height) - [Why should line-height be unitless in CSS?](https://www.30secondsofcode.org/css/s/unitless-line-height/) - [How to Tame Line Height in CSS — CSS-Tricks](https://css-tricks.com/how-to-tame-line-height-in-css/) - [Deep dive CSS: font metrics, line-height and vertical-align](https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align) - [Line-height tricks with the lh unit — Dan Burzo](https://danburzo.ro/line-height-lh/) --- # Noto Sans Webfont Guide > Source: https://takazudomodular.com/pj/zcss/docs/typography/fonts/noto-sans-webfont-guide ## The Problem AI agents loading Noto Sans (especially Noto Sans JP for Japanese content) commonly make a few mistakes: loading all nine weights when only two or three are needed, omitting `font-display: swap`, or not using subsetting — resulting in megabytes of font data blocking page render. For Japanese, the default Noto Sans JP file is several MB; without subsetting it can be the single largest asset on the page. A subtler problem: AI agents default to using `font-weight: bold` for headings and `font-weight: normal` for body text, missing the more refined hierarchy that Noto Sans's wide weight range enables — such as light (300) body text with regular (400) headings, or regular body with medium (500) headings. This approach creates visual differentiation through weight contrast rather than just bold vs. normal. ## The Solution Use Noto Sans with deliberate weight selection, proper subsetting, and `font-display: swap`. Define your font stack as a CSS custom property so it can be reused consistently, and choose weights that create the hierarchy you want — not just bold vs. normal. ## Code Examples ### Loading via Google Fonts CDN The simplest approach. Google Fonts automatically handles subsetting and returns only the glyphs needed for the language detected in the request. ```html ``` ```css :root { --font-noto: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; } body { font-family: var(--font-noto); font-weight: 300; /* Light — creates breathing room in body text */ } h1, h2, h3 { font-family: var(--font-noto); font-weight: 400; /* Regular — stands out against light body without going bold */ } ``` ### Loading via @fontsource (Self-hosted) @fontsource packages allow self-hosting with subsetting. Install only the weights you need: ```bash # Install Noto Sans JP — only weights 300 and 400 npm install @fontsource/noto-sans-jp ``` ```css /* Import only the weights you actually use */ @import '@fontsource/noto-sans-jp/300.css'; @import '@fontsource/noto-sans-jp/400.css'; @import '@fontsource/noto-sans-jp/500.css'; ``` ### Loading via Next.js `next/font/google` Next.js `next/font/google` automatically optimizes the font: self-hosts it, eliminates the external request, and inlines critical CSS. ```tsx const notoSans = Noto_Sans_JP({ weight: ['300', '400', '500'], // Only load what you use subsets: ['latin'], variable: '--font-noto', display: 'swap', }); return ( {children} ); } ``` ```css body { font-family: var(--font-noto), 'Hiragino Sans', sans-serif; } ``` ### Loading in Docusaurus In Docusaurus, add the font link to your `docusaurus.config.ts` and apply it via `custom.css`: ```ts // docusaurus.config.ts const config = { headTags: [ { tagName: 'link', attributes: { rel: 'preconnect', href: 'https://fonts.googleapis.com', }, }, { tagName: 'link', attributes: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous', }, }, { tagName: 'link', attributes: { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500&display=swap', }, }, ], }; ``` ```css /* src/css/custom.css */ :root { --ifm-font-family-base: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; --ifm-font-weight-base: 300; } h1, h2, h3, h4, h5, h6 { font-weight: 400; } ``` ### Font Weight Hierarchy Patterns 見出し:Noto Sans JP で読みやすいタイポグラフィ 本文テキスト:ウェイト300(Light)を使うと、文章が軽やかに見えます。長い段落でも目が疲れにくく、読みやすい印象を与えます。見出しはウェイト400(Regular)で、本文との差をつけています。 セクション見出し(Regular / 400) このパターンは、コンテンツが多いサイトや、長文記事に適しています。ウェイトの差が控えめなので、洗練された印象を与えます。The quick brown fox jumps over the lazy dog. `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400&display=swap'); .article { padding: 2rem; max-width: 600px; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(0 0% 99%); border-radius: 8px; } .article__title { font-size: 1.5rem; font-weight: 400; line-height: 1.4; margin: 0 0 1rem; color: hsl(220 20% 15%); } .article__heading { font-size: 1.1rem; font-weight: 400; margin: 1.5rem 0 0.5rem; color: hsl(220 20% 15%); } .article__body { font-size: 1rem; font-weight: 300; line-height: 1.75; color: hsl(220 15% 30%); margin: 0 0 1rem; }`} height={380} /> 見出し:Noto Sans JP で読みやすいタイポグラフィ 本文テキスト:ウェイト400(Regular)は最も標準的な本文ウェイトです。見出しにウェイト500(Medium)を使うと、太字ほど主張せずに、しっかりした階層を表現できます。 セクション見出し(Medium / 500) このパターンは、ダッシュボードやUIが多いアプリに適しています。情報密度が高い画面でも、見出しが適切に目立ちます。The quick brown fox jumps over the lazy dog. `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500&display=swap'); .article { padding: 2rem; max-width: 600px; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(220 20% 97%); border-radius: 8px; } .article__title { font-size: 1.5rem; font-weight: 500; line-height: 1.4; margin: 0 0 1rem; color: hsl(220 20% 15%); } .article__heading { font-size: 1.1rem; font-weight: 500; margin: 1.5rem 0 0.5rem; color: hsl(220 20% 15%); } .article__body { font-size: 1rem; font-weight: 400; line-height: 1.75; color: hsl(220 15% 30%); margin: 0 0 1rem; }`} height={380} /> ### Weight Comparison across the Full Range 100 Thin Typography · タイポグラフィ · 활자 200 ExtraLight Typography · タイポグラフィ · 활자 300 Light Typography · タイポグラフィ · 활자 400 Regular Typography · タイポグラフィ · 활자 500 Medium Typography · タイポグラフィ · 활자 600 SemiBold Typography · タイポグラフィ · 활자 700 Bold Typography · タイポグラフィ · 활자 800 ExtraBold Typography · タイポグラフィ · 활자 900 Black Typography · タイポグラフィ · 활자 `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100;200;300;400;500;600;700;800;900&display=swap'); .weight-showcase { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(0 0% 99%); } .weight-row { display: flex; align-items: baseline; gap: 1.25rem; padding: 0.4rem 0.75rem; border-radius: 6px; background: hsl(220 15% 96%); } .weight-row__label { font-size: 0.7rem; color: hsl(260 60% 55%); font-weight: 600; min-width: 120px; flex-shrink: 0; letter-spacing: 0.02em; } .weight-row__sample { font-size: 1.05rem; color: hsl(220 20% 15%); }`} height={480} /> ### CSS Variable Approach for Easy Switching Noto Sans JP 本文テキストのサンプルです。CSS変数を使うことで、フォントファミリーをまとめて管理できます。The quick brown fox. font-family: var(--font-sans) Monospace Fallback コードブロックや等幅表示には別のCSS変数を使います。Consistent spacing in code examples. font-family: var(--font-mono) `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500&display=swap'); :root { --font-sans: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; --font-weight-body: 300; --font-weight-heading: 400; } .theme-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; background: hsl(0 0% 99%); } .theme-demo__card { padding: 1.25rem; border-radius: 8px; border-left: 4px solid hsl(260 60% 55%); background: hsl(220 15% 96%); } .theme-demo__card--mono { border-left-color: hsl(200 70% 50%); } .theme-demo__card-title { margin: 0 0 0.5rem; font-size: 0.85rem; font-weight: 600; color: hsl(260 60% 50%); font-family: var(--font-sans); } .theme-demo__card--mono .theme-demo__card-title { color: hsl(200 70% 45%); } .theme-demo__card-body { margin: 0 0 0.75rem; font-size: 1rem; line-height: 1.7; font-family: var(--font-sans); font-weight: var(--font-weight-body); color: hsl(220 15% 25%); } .theme-demo__card--mono .theme-demo__card-body { font-family: var(--font-mono); font-weight: 400; } .theme-demo__card-code { display: block; font-family: var(--font-mono); font-size: 0.8rem; padding: 0.4rem 0.6rem; background: hsl(220 15% 88%); border-radius: 4px; color: hsl(220 15% 35%); }`} height={340} /> ### Performance: Subsetting Japanese Fonts Noto Sans JP covers all Japanese characters — this makes the full font file large (several MB). Google Fonts handles subsetting automatically based on the text on the page. For self-hosted fonts, use `unicode-range` to split by script: ```css /* Latin characters — small subset */ @font-face { font-family: 'Noto Sans JP'; src: url('/fonts/noto-sans-jp-latin.woff2') format('woff2'); font-weight: 300; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215; } /* Japanese characters — larger file, loaded only when Japanese text is present */ @font-face { font-family: 'Noto Sans JP'; src: url('/fonts/noto-sans-jp-japanese.woff2') format('woff2'); font-weight: 300; font-display: swap; unicode-range: U+3000-9FFF, U+F900-FAFF, U+FF00-FFEF; } ``` For Google Fonts with `display=swap` already appended, the URL approach handles this automatically — you don't need to manage `unicode-range` manually. ## Common AI Mistakes - Loading all 9 weights (`wght@100;200;300;400;500;600;700;800;900`) when the design only uses 2–3 — this wastes bandwidth and slows page load significantly for Japanese fonts - Omitting `display=swap` in Google Fonts URLs, which causes FOIT (Flash of Invisible Text) on slow connections - Using `font-weight: bold` (700) for headings when the body is `font-weight: normal` (400) — this creates an abrupt jump; more refined designs use smaller weight increments like 300/400 or 400/500 - Not including the `preconnect` hints before the Google Fonts ``, adding unnecessary latency to font requests - Referencing Noto Sans without the JP variant (`'Noto Sans'` instead of `'Noto Sans JP'`) when the page contains Japanese text — the base Noto Sans does not include Japanese glyphs - Using a flat font family string without a CSS variable, making it hard to change the font stack consistently across the codebase - Self-hosting Noto Sans JP without subsetting — the full file can be 4–8 MB per weight, making self-hosting impractical without a build tool that handles subsetting ## When to Use ### Light body (300) + Regular headings (400) - Long-form content, editorial sites, documentation - Designs aiming for an elegant, refined appearance - Dark-on-light color schemes where lighter text reduces visual fatigue ### Regular body (400) + Medium headings (500) - UI-heavy applications, dashboards, admin interfaces - Designs where information density is high and clear hierarchy matters - Pages where both body and heading text need to be readable at small sizes ### Using Noto Sans JP specifically - Any page containing Japanese, Korean, or CJK characters - Multilingual sites that need consistent rendering across scripts - Projects where a neutral, highly legible typeface is preferred over a distinctive design voice ## References - [Noto Sans — Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+JP) - [Optimize WebFont loading — web.dev](https://web.dev/articles/optimize-webfont-loading) - [Font best practices — web.dev](https://web.dev/articles/font-best-practices) - [@fontsource/noto-sans-jp — npm](https://www.npmjs.com/package/@fontsource/noto-sans-jp) - [next/font — Next.js docs](https://nextjs.org/docs/app/api-reference/components/font) --- # Prose Heading Spacing > Source: https://takazudomodular.com/pj/zcss/docs/typography/text-control/prose-heading-spacing ## The Problem Markdown-to-HTML converters produce flat sequences of block elements — paragraphs, lists, tables, and headings. A common spacing strategy is to give every block element the same bottom margin for uniform rhythm. Headings then get additional top margin so readers perceive section boundaries. This works until headings appear consecutively: ```html Getting Started Prerequisites Node.js Version Install Node.js 20 or later. ``` Each heading carries its own large top margin. When they stack, the accumulated whitespace creates a visual gap that looks broken — far larger than any other spacing on the page. The problem worsens inside flex or grid containers where margins do not collapse. MDX compounds this because component wrappers and admonitions can interrupt the expected heading-to-content flow, making rigid margin rules fragile. Normal content flow Section Title Paragraph text with standard spacing below. The rhythm feels consistent and readable. Another paragraph maintains the same spacing. Next Section The extra space above the heading creates clear section separation. Consecutive headings Getting Started Prerequisites Node.js Version Each heading added its own large top margin, creating a gap that looks broken. Next Section Overview The problem repeats every time headings stack. `} css={` .prose-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .prose-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } .prose-normal h2, .prose-broken h2 { font-size: 1.3rem; line-height: 1.3; margin: 2.5rem 0 0.75rem; color: hsl(220, 25%, 15%); } .prose-normal h2:first-child, .prose-broken h2:first-child { margin-top: 0; } .prose-normal h3, .prose-broken h3 { font-size: 1.1rem; line-height: 1.3; margin: 2rem 0 0.5rem; color: hsl(220, 20%, 25%); } .prose-normal h4, .prose-broken h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5rem 0 0.5rem; color: hsl(220, 15%, 35%); } .prose-normal p, .prose-broken p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1rem; color: hsl(220, 10%, 35%); } `} height={380} /> ## The Solution Decouple spacing from individual elements. Instead of each element owning its own margins, make spacing a property of the **relationship between siblings**. This is the "flow" pattern: a parent container rule controls the gap between adjacent children, and headings override that gap to create section separation. The consecutive heading problem is then solved with a single override: when a heading follows another heading, tighten the spacing back down. Three production-ready strategies exist, each with different trade-offs. ## Code Examples ### Strategy 1: Flow Utility with Heading Override The flow utility uses the lobotomized owl selector (`* + *`) scoped to a container. Each heading sets a larger flow space. One rule tightens consecutive headings. ```css .prose > * + * { margin-block-start: var(--flow-space, 1em); } /* Headings create section separation */ .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } /* Consecutive headings: tighten spacing */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .prose > :first-child { margin-block-start: 0; } ``` This is the most robust approach. Spacing is owned by one margin direction only (`margin-block-start`), so it works identically in block flow, flex, and grid — no margin collapse dependency. Without fix (flow only) Getting Started Prerequisites Required Tools Paragraph after consecutive headings. Second paragraph with normal spacing. Installation Content with standard flow spacing. With consecutive heading fix Getting Started Prerequisites Required Tools Paragraph after consecutive headings. Second paragraph with normal spacing. Installation Content with standard flow spacing. `} css={` .flow-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .flow-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- No fix version --- */ .flow-no-fix > * + * { margin-block-start: var(--flow-space, 1em); } .flow-no-fix :where(h2) { --flow-space: 2.5em; } .flow-no-fix :where(h3) { --flow-space: 2em; } .flow-no-fix :where(h4) { --flow-space: 1.5em; } .flow-no-fix > :first-child { margin-block-start: 0; } /* --- Fixed version --- */ .flow-fixed > * + * { margin-block-start: var(--flow-space, 1em); } .flow-fixed :where(h2) { --flow-space: 2.5em; } .flow-fixed :where(h3) { --flow-space: 2em; } .flow-fixed :where(h4) { --flow-space: 1.5em; } .flow-fixed :where(h2, h3, h4, h5) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .flow-fixed > :first-child { margin-block-start: 0; } /* Shared heading/paragraph styles */ .flow-no-fix h2, .flow-fixed h2 { font-size: 1.3rem; line-height: 1.3; color: hsl(220, 25%, 15%); margin: 0; } .flow-no-fix h3, .flow-fixed h3 { font-size: 1.1rem; line-height: 1.3; color: hsl(220, 20%, 25%); margin: 0; } .flow-no-fix h4, .flow-fixed h4 { font-size: 0.95rem; line-height: 1.3; color: hsl(220, 15%, 35%); margin: 0; } .flow-no-fix p, .flow-fixed p { font-size: 0.9rem; line-height: 1.6; color: hsl(220, 10%, 35%); margin: 0; } `} height={380} /> ### Strategy 2: Tailwind Typography Style Tailwind Typography takes a different approach: headings own both top and bottom margins, and a wildcard rule zeroes the top margin of whatever follows a heading. ```css .prose h2 { margin-block: 2em 1em; } .prose h3 { margin-block: 1.6em 0.6em; } .prose h4 { margin-block: 1.5em 0.5em; } .prose p, .prose ul, .prose ol, .prose table, .prose pre { margin-block-end: 1em; } /* Zero out next sibling's top margin after any heading */ .prose :is(h2, h3, h4) + * { margin-block-start: 0; } ``` When `h2` is followed by `h3`, the `h3`'s top margin becomes `0`. Only the `h2`'s bottom margin (`1em`) remains as spacing. This handles consecutive headings without explicit pair rules. The trade-off: this zeros the top margin of **any** element after a heading, not just other headings. That means the first paragraph after a heading is always tight to the heading — which is usually desirable, but removes the ability to fine-tune that gap independently. Without heading + * rule Getting Started Prerequisites Required Tools Paragraph spacing controlled by own margins. Consistent bottom margin on paragraphs. Installation Heading top margins accumulate. With heading + * rule Getting Started Prerequisites Required Tools Top margin zeroed — heading's bottom margin controls gap. Consistent bottom margin on paragraphs. Installation Clean spacing everywhere. `} css={` .tw-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .tw-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- No fix --- */ .tw-no-fix h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .tw-no-fix h2:first-child { margin-top: 0; } .tw-no-fix h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .tw-no-fix h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .tw-no-fix p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* --- Fixed --- */ .tw-fixed h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .tw-fixed h2:first-child { margin-top: 0; } .tw-fixed h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .tw-fixed h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .tw-fixed p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } .tw-fixed :is(h2, h3, h4) + * { margin-block-start: 0; } `} height={380} /> ### Strategy 3: `:has()` — Target the Preceding Heading The `:has()` selector enables parent-side control: reduce a heading's bottom margin when another heading follows it. ```css :is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) { margin-block-end: 0.25em; } ``` This targets the **first** heading in a consecutive pair and reduces its bottom margin. The second heading keeps its normal top margin. Combined, the total gap shrinks to a reasonable size. This approach is precise — it only adjusts spacing between heading pairs, leaving heading-to-content spacing untouched. Browser support is universal in modern browsers (Chrome 105+, Firefox 121+, Safari 15.4+). The trade-off: this strategy still depends on both headings having their own margins (top and bottom). In flex or grid containers where margins do not collapse, the gap between consecutive headings remains larger than in block flow. Use the flow utility (Strategy 1) or the Tailwind style (Strategy 2) when the prose container may be a flex or grid column. Without :has() rule Getting Started Prerequisites Required Tools Paragraph after headings with default spacing. Installation Quick Setup Another section with consecutive headings. With :has() rule Getting Started Prerequisites Required Tools Paragraph spacing is unchanged — only heading pairs tightened. Installation Quick Setup Precise control over heading-to-heading gaps. `} css={` .has-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .has-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* Shared base styles */ .has-no-fix h2, .has-fixed h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .has-no-fix h2:first-child, .has-fixed h2:first-child { margin-top: 0; } .has-no-fix h3, .has-fixed h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .has-no-fix h4, .has-fixed h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .has-no-fix p, .has-fixed p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* :has() fix */ .has-fixed :is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) { margin-block-end: 0.25em; } `} height={380} /> ### The Flex/Grid Margin Collapse Trap In normal block flow, adjacent vertical margins collapse — the larger margin wins. Many prose spacing strategies rely on this behavior implicitly. But flex and grid containers **do not collapse margins**. Both margins apply in full, doubling the gap. This matters because markdown content containers are increasingly rendered inside flex or grid layouts (for sidebars, table of contents panels, or multi-column layouts). ```css /* This relies on margin collapse — breaks in flex/grid */ .prose-block h2 { margin-block: 2em 1em; } .prose-block h3 { margin-block: 1.6em 0.6em; } /* h2 bottom (1em) + h3 top (1.6em) = 1.6em in block flow (collapsed) */ /* h2 bottom (1em) + h3 top (1.6em) = 2.6em in flex/grid (stacked) */ /* This works everywhere — only one margin per gap */ .prose-flow > * + * { margin-block-start: var(--flow-space, 1em); } ``` Block flow (margins collapse) Section A Subsection In block flow, h2 bottom margin and h3 top margin collapse to the larger value. Section B Spacing looks reasonable. Flex container (no collapse) Section A Subsection In flex, both margins apply. The gap between headings is much larger. Section B Same CSS rules, different result. `} css={` .collapse-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .collapse-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* Block flow — margins collapse */ .collapse-block { display: block; } .collapse-block h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .collapse-block h2:first-child { margin-top: 0; } .collapse-block h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .collapse-block p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* Flex container — no margin collapse */ .collapse-flex { display: flex; flex-direction: column; } .collapse-flex h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .collapse-flex h2:first-child { margin-top: 0; } .collapse-flex h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .collapse-flex p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } `} height={350} /> ### Combined Approach for Production A production prose container can combine the flow utility with `:has()` for maximum control: ```css .prose > * + * { margin-block-start: var(--flow-space, 1em); } /* Section separation before headings */ .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } /* Tighter gap between heading and its first content */ .prose :where(h2, h3, h4) + :where(p, ul, ol, table, pre) { --flow-space: 0.5em; } /* Consecutive headings: tight grouping */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } /* Trim edges */ .prose > :first-child { margin-block-start: 0; } .prose > :last-child { margin-block-end: 0; } ``` ### Fine-Tuning Specific Heading Pairs The combined approach treats all consecutive heading pairs identically. When the design requires different spacing between specific pairs — for example, more room between h2 and h3 (a major section to subsection transition) than between h3 and h4 (a minor nesting step) — add explicit pair overrides on top: ```css /* Base: all consecutive headings get the same tight spacing */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } /* Fine-tune: h2 → h3 gets slightly more room */ .prose :where(h2) + :where(h3) { --flow-space: 0.75em; } /* Fine-tune: h3 → h4 stays tighter */ .prose :where(h3) + :where(h4) { --flow-space: 0.4em; } ``` The `:where()` wrapper keeps specificity flat, so declaration order controls which rule wins. Place pair-specific overrides **after** the catch-all consecutive heading rule. Uniform consecutive spacing Getting Started Prerequisites Node.js Version All heading pairs use the same tight gap. Configuration Basic Setup No distinction between pair types. Pair-specific tuning Getting Started Prerequisites Node.js Version h2→h3 has more room than h3→h4. Configuration Basic Setup Hierarchy is visually clearer. `} css={` .pair-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .pair-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- Uniform --- */ .pair-uniform > * + * { margin-block-start: var(--flow-space, 1em); } .pair-uniform :where(h2) { --flow-space: 2.5em; } .pair-uniform :where(h3) { --flow-space: 2em; } .pair-uniform :where(h4) { --flow-space: 1.5em; } .pair-uniform :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .pair-uniform > :first-child { margin-block-start: 0; } .pair-uniform h2, .pair-tuned h2 { font-size: 1.3rem; line-height: 1.3; color: hsl(220, 25%, 15%); margin: 0; } .pair-uniform h3, .pair-tuned h3 { font-size: 1.1rem; line-height: 1.3; color: hsl(220, 20%, 25%); margin: 0; } .pair-uniform h4, .pair-tuned h4 { font-size: 0.95rem; line-height: 1.3; color: hsl(220, 15%, 35%); margin: 0; } .pair-uniform p, .pair-tuned p { font-size: 0.9rem; line-height: 1.6; color: hsl(220, 10%, 35%); margin: 0; } /* --- Tuned --- */ .pair-tuned > * + * { margin-block-start: var(--flow-space, 1em); } .pair-tuned :where(h2) { --flow-space: 2.5em; } .pair-tuned :where(h3) { --flow-space: 2em; } .pair-tuned :where(h4) { --flow-space: 1.5em; } .pair-tuned :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .pair-tuned :where(h2) + :where(h3) { --flow-space: 0.75em; } .pair-tuned :where(h3) + :where(h4) { --flow-space: 0.4em; } .pair-tuned > :first-child { margin-block-start: 0; } `} height={340} /> This is an optional refinement layer. Most prose layouts work well with uniform consecutive heading spacing. Add pair-specific overrides only when the design explicitly calls for visible hierarchy distinction between heading transitions. ## Quick Reference | Scenario | Strategy | Key Selector | | --- | --- | --- | | Uniform sibling spacing | Flow utility | `.prose > * + * { margin-block-start: ... }` | | Heading section separation | Override `--flow-space` | `.prose :where(h2) { --flow-space: 2.5em }` | | Consecutive heading tightening | Adjacent heading selector | `:where(h2,h3,h4) + :where(h2,h3,h4,h5,h6)` | | Zero next-sibling after heading | Tailwind style | `:is(h2,h3,h4) + * { margin-block-start: 0 }` | | Reduce preceding heading margin | `:has()` selector | `:is(h2,h3):has(+ :is(h3,h4,h5)) { margin-block-end: ... }` | | Pair-specific heading tuning | Explicit pair overrides | `:where(h2) + :where(h3) { --flow-space: 0.75em }` | | Flex/grid safe spacing | Single-direction margins | Use `margin-block-start` only, never both directions | ## Common AI Mistakes - **Setting both `margin-top` and `margin-bottom` on headings without accounting for flex/grid contexts.** Margins collapse in block flow but stack in flex/grid. The same CSS produces different spacing depending on the parent container. - **Adding large `margin-top` to every heading without a consecutive heading override.** This causes h2 + h3 + h4 stacks to produce enormous gaps. - **Relying on margin collapse as a spacing strategy.** Margin collapse is implicit behavior that breaks when the layout context changes. Explicit single-direction margins are more predictable. **Never design spacing that depends on margin collapse.** It is uncontrollable — margins collapse in block flow but not in flex, grid, or beside floats. When an element has both `margin-bottom` and the next element has `margin-top`, the resulting gap changes depending on the parent layout context. Use only `margin-block-start` via the flow utility and padding for internal spacing within components. If a component needs bottom spacing for visual weight (e.g., decorative borders on headings), use `padding-bottom`, not `margin-bottom`, so it does not interact with the flow utility's `margin-block-start` on the next element. - **Using different margin values for the same element type.** One paragraph gets `margin-bottom: 16px`, another gets `20px`. Uniform spacing from a flow utility prevents this. - **Not zeroing `margin-block-start` on the first child.** The first element in a prose container should sit flush against the container edge. Without `:first-child { margin-block-start: 0 }`, the flow utility adds unwanted top space. - **Forgetting to tighten heading-to-content spacing.** Headings need less space below them than above — the heading should visually "belong" to the content that follows it, not float equidistant between sections. ## When to Use ### Flow Utility with Heading Override The default choice for markdown/MDX prose containers. Works in any layout context. Scales to any number of heading levels. Use this when building documentation sites, blog post templates, or any content-heavy layout. ### Tailwind Typography Style Use when adopting Tailwind's `@tailwindcss/typography` plugin or building a similar opinionated prose system. The `heading + *` wildcard is simple but removes fine-grained control over heading-to-content gaps. ### `:has()` Selector Approach Use as a targeted fix on top of existing heading margins in block flow containers. Particularly useful when retrofitting spacing to an existing stylesheet without restructuring the margin strategy. Does not require adopting the flow utility pattern. Avoid in flex or grid containers where margins do not collapse — the gap will remain larger than expected. ### Explicit Pair Rules Use only when the content structure is strictly controlled (e.g., a CMS that limits heading nesting) and the number of heading combinations is small. Does not scale to arbitrary markdown content. ## Tailwind CSS The complex adjacent sibling selectors (`:where(h2,h3,h4,h5,h6) + :where(h2,h3,h4,h5,h6)`) cannot be expressed with Tailwind utility classes alone. Three approaches exist: ### Use `@tailwindcss/typography` The `@tailwindcss/typography` plugin already handles consecutive headings. Apply the `prose` class and heading spacing is managed automatically — including the `h2 + *`, `h3 + *`, `h4 + *` reset pattern described in Strategy 2. ```html Getting Started Prerequisites Typography plugin handles the spacing. ``` ### Write Custom CSS in Tailwind v4 Tailwind v4's CSS-first configuration allows writing selectors directly in your stylesheet. Add heading spacing rules in a `@layer`: ```css @layer components { .prose > * + * { margin-block-start: var(--flow-space, 1em); } .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .prose > :first-child { margin-block-start: 0; } } ``` This is the recommended approach when not using `@tailwindcss/typography` — full selector control with Tailwind's layer system. ### Tailwind Utilities for Simple Cases For simple prose layouts where only one level of heading override is needed, Tailwind's arbitrary variant syntax works: ```html *+*]:mt-4 [&>h2]:mt-10 [&>h3]:mt-8"> Section Subsection Content ``` This does not handle the consecutive heading problem. For that, the arbitrary variant would be: ```html :is(h2,h3,h4)+:is(h2,h3,h4,h5,h6)]:mt-2"> ... ``` This works but is difficult to read and maintain. Prefer the CSS `@layer` approach or `@tailwindcss/typography` over long arbitrary variants. Without prose class Getting Started Prerequisites Node.js Version Large gaps between consecutive headings — each heading adds its own top margin. Installation Normal spacing after a single heading. With prose class Getting Started Prerequisites Node.js Version Typography plugin tightens consecutive headings automatically. Installation Normal spacing after a single heading. `} height={400} /> ## References - [Axiomatic CSS and Lobotomized Owls — Heydon Pickering, A List Apart](https://alistapart.com/article/axiomatic-css-and-lobotomized-owls) - [The Stack — Every Layout](https://every-layout.dev/layouts/stack/) - [CUBE CSS — Andy Bell](https://piccalil.li/blog/cube-css/) - [Tailwind Typography source (styles.js)](https://github.com/tailwindlabs/tailwindcss-typography/blob/master/src/styles.js) - [Mastering Margin Collapsing — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsing) - [The :has() selector — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:has) - [The :is() selector — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:is) - [Everything You Need To Know About CSS Margins — Smashing Magazine](https://www.smashingmagazine.com/2019/07/margins-in-css/) --- # Responsive Grid Patterns > Source: https://takazudomodular.com/pj/zcss/docs/responsive/responsive-grid-patterns ## The Problem Creating responsive grid layouts traditionally requires multiple media queries with different `grid-template-columns` values at each breakpoint. AI agents almost always hard-code column counts (e.g., `grid-template-columns: repeat(3, 1fr)`) and then add breakpoints to switch to 2 columns and then 1 column. This produces brittle layouts that break when the container width changes unexpectedly. CSS Grid has built-in features that make grids inherently responsive without any media queries. ## The Solution Use `repeat()` with `auto-fill` or `auto-fit` combined with `minmax()` to create grids that automatically adjust their column count based on available space. This is sometimes called the **RAM pattern** (Repeat, Auto, Minmax). ### auto-fill vs. auto-fit - **`auto-fill`**: Creates as many tracks as will fit in the container. Empty tracks remain and take up space. - **`auto-fit`**: Creates as many tracks as will fit, but collapses empty tracks to zero width, allowing filled tracks to stretch. When the grid has fewer items than columns, the difference becomes visible: - `auto-fill` preserves the empty column slots (useful for consistent column widths). - `auto-fit` collapses empty slots and stretches existing items to fill the row. Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={` .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); gap: 1rem; padding: 1rem; } .grid-card { color: white; font-weight: 700; font-size: 1rem; padding: 2rem 1rem; border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; min-height: 80px; } `} /> ## Code Examples ### Basic Responsive Grid (RAM Pattern) ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.5rem; } ``` This single line creates a grid where: - Each column is at least `250px` wide. - Columns grow equally to fill remaining space (`1fr`). - As the container shrinks, columns automatically wrap to fewer per row. - No media queries needed. ### auto-fill: Consistent Column Slots ```css .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; } ``` ```html Card 1 Card 2 ``` Use `auto-fill` when you want consistent column sizing even with fewer items than available slots. ### auto-fit: Items Stretch to Fill ```css .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } ``` ```html Card 1 Card 2 ``` Use `auto-fit` when you want items to expand and fill the available space regardless of item count. ### Preventing Overflow with min() A common issue with `minmax(250px, 1fr)` is that on viewports narrower than `250px`, the grid overflows. Use `min()` to fix this: ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); gap: 1.5rem; } ``` `min(250px, 100%)` ensures columns never exceed the container width, even on very narrow screens. ### Card Grid with Consistent Heights ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 1.5rem; } .card { display: flex; flex-direction: column; background: var(--color-surface, #f5f5f5); border-radius: 0.5rem; overflow: hidden; } .card__body { flex: 1; padding: 1.5rem; } .card__footer { padding: 1rem 1.5rem; margin-block-start: auto; } ``` ### Responsive Grid with Minimum Column Count Sometimes you want at least 2 columns even on narrow screens. Combine the RAM pattern with a media query only for the smallest size: ```css .grid-min-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } @media (min-width: 40rem) { .grid-min-2 { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } } ``` ### Asymmetric Responsive Layout For layouts where one column should be wider (e.g., main content + sidebar): ```css .layout { display: grid; grid-template-columns: 1fr; gap: 2rem; } @media (min-width: 50rem) { .layout { grid-template-columns: 1fr 20rem; } } ``` ### Dense Packing for Varied-Size Items When grid items have different spans, use `grid-auto-flow: dense` to fill gaps: ```css .masonry-like { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-auto-flow: dense; gap: 1rem; } .masonry-like .wide { grid-column: span 2; } .masonry-like .tall { grid-row: span 2; } ``` ### Responsive Grid with Subgrid for Aligned Content When card content (title, text, footer) needs to align across a row: ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: 1.5rem; } @supports (grid-template-rows: subgrid) { .card-grid { grid-template-rows: auto; } .card { display: grid; grid-template-rows: subgrid; grid-row: span 3; /* title, body, footer */ } } ``` ## Common AI Mistakes - **Hard-coding column counts**: Writing `grid-template-columns: repeat(3, 1fr)` and then adding media queries to change to 2 and 1 columns, instead of using `auto-fill`/`auto-fit` with `minmax()`. - **Confusing `auto-fill` and `auto-fit`**: Using them interchangeably. When the grid has fewer items than columns, they behave differently. - **Not preventing overflow**: Using `minmax(250px, 1fr)` without `min(250px, 100%)`, causing horizontal overflow on narrow viewports. - **Using Flexbox for grid layouts**: Reaching for `display: flex; flex-wrap: wrap` with percentage widths and gap hacks when CSS Grid provides a cleaner solution with `auto-fill`. - **Overusing media queries**: Adding breakpoints for every column count change instead of letting `auto-fill`/`auto-fit` handle it automatically. - **Ignoring `grid-auto-flow: dense`**: Leaving gaps in the grid when items have different sizes, instead of using dense packing. ## When to Use - **Card grids**: Product listings, blog post grids, image galleries — any uniform-item grid. - **Dashboard layouts**: Widgets or panels that should fill available space. - **`auto-fill`**: When you want consistent column widths even with fewer items (e.g., a product grid that should keep its structure). - **`auto-fit`**: When you want items to stretch and fill the row (e.g., a hero section with 1-3 feature cards). - **Not for complex asymmetric layouts**: Use explicit `grid-template-columns` and `grid-template-areas` for layouts with sidebars, headers, and footers. ## Tailwind CSS Tailwind uses responsive breakpoint prefixes (`sm:`, `md:`, `lg:`, `xl:`) to change grid column counts at different viewport widths. Use the viewport buttons to see the grid reflow. ### Responsive Card Grid Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ### Asymmetric Layout Main Content Takes full width on mobile, shares space with sidebar on wider screens. Sidebar Fixed 16rem width on desktop. `} height={260} /> ## References - [Auto-Sizing Columns in CSS Grid: auto-fill vs auto-fit — CSS-Tricks](https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/) - [minmax() — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/minmax) - [Auto-placement in Grid Layout — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Auto-placement) - [Responsive CSS Grid Layouts — Harshal V. Ladhe](https://harshal-ladhe.netlify.app/post/responsive-css-grid-layouts) --- # Color Contrast and Accessibility > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/color-contrast-accessibility ## The Problem Color contrast is the number one accessibility violation on the web. WebAIM's annual analysis consistently finds that over 80% of homepages have low-contrast text. AI agents frequently generate designs with light gray text on white backgrounds, placeholder text that fails contrast requirements, decorative color choices that prioritize aesthetics over readability, and interactive elements that are indistinguishable from surrounding content. The result is text that is difficult or impossible to read for users with low vision, color blindness, or in challenging viewing conditions (bright sunlight, dim screens). ## The Solution WCAG (Web Content Accessibility Guidelines) defines minimum contrast ratios between foreground and background colors. Meeting these ratios ensures text is readable for the widest range of users, including those with visual impairments. ### WCAG Contrast Requirements | Level | Normal text (< 18pt / < 14pt bold) | Large text (≥ 18pt / ≥ 14pt bold) | UI components | | ----- | ----------------------------------- | ---------------------------------- | ------------- | | AA | 4.5:1 | 3:1 | 3:1 | | AAA | 7:1 | 4.5:1 | — | "Large text" is defined as 18pt (24px) or 14pt (18.67px) bold and above. ## Code Examples ### Safe Color Combinations ```css /* PASS AA — dark text on light background */ .text-on-light { color: oklch(25% 0.02 264); /* ~#1a1a2e */ background: oklch(98% 0.005 264); /* ~#f8f8fc */ /* Contrast ratio: ~15:1 ✓ */ } /* PASS AA — light text on dark background */ .text-on-dark { color: oklch(90% 0.01 264); /* ~#e0e0f0 */ background: oklch(18% 0.015 264); /* ~#1e1e30 */ /* Contrast ratio: ~11:1 ✓ */ } /* FAIL AA — light gray on white */ .text-low-contrast { color: oklch(70% 0 0); /* ~#a0a0a0 */ background: oklch(100% 0 0); /* white */ /* Contrast ratio: ~2.6:1 ✗ */ } ``` PASS AA Dark text on light background This text has a contrast ratio of approximately 15:1 — well above the WCAG AA minimum of 4.5:1. PASS AA Light text on dark background This text has a contrast ratio of approximately 11:1 — excellent readability on dark surfaces. FAIL AA Light gray on white This text has a contrast ratio of approximately 2.6:1 — fails WCAG AA. Hard to read, especially for users with low vision. FAIL AA Low contrast colored text Yellow-toned text on a light yellow background — fails contrast requirements despite looking "colorful." `} css={`.contrast-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; font-family: system-ui, sans-serif; } .example { display: flex; align-items: flex-start; gap: 0.75rem; } .badge { font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.5rem; border-radius: 4px; white-space: nowrap; flex-shrink: 0; margin-top: 0.5rem; } .badge.good { background: oklch(90% 0.08 145); color: oklch(30% 0.1 145); } .badge.bad { background: oklch(90% 0.08 25); color: oklch(35% 0.15 25); } .text-sample { padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.9rem; line-height: 1.5; flex: 1; } .text-sample strong { display: block; margin-bottom: 0.25rem; }`} height={370} /> ### Accessible Brand Colors ```css :root { /* Brand blue — test against both light and dark backgrounds */ --brand: oklch(45% 0.2 264); /* ✓ On white (contrast ~7:1) */ --brand-on-light: oklch(45% 0.2 264); /* ✓ On dark bg (contrast ~5:1) - lighter variant needed */ --brand-on-dark: oklch(72% 0.15 264); } /* Apply contextually */ .light-section a { color: var(--brand-on-light); } .dark-section a { color: var(--brand-on-dark); } ``` ### Accessible Placeholder Text ```css /* WRONG: Default placeholder is typically too light */ input::placeholder { color: oklch(75% 0 0); /* ~#b0b0b0 — fails 4.5:1 on white */ } /* CORRECT: Darker placeholder that passes contrast */ input::placeholder { color: oklch(48% 0 0); /* ~#6b6b6b — passes 4.5:1 on white */ } /* Always provide visible labels — don't rely on placeholder as label */ ``` ### Accessible Disabled States ```css /* Disabled elements are exempt from WCAG contrast requirements, but they should still be distinguishable from the background */ .button:disabled { color: oklch(60% 0 0); background: oklch(90% 0 0); cursor: not-allowed; /* Contrast ~2.5:1 — enough to see it exists, clearly different from active buttons */ } /* But NEVER use low contrast for text users need to read */ ``` ### Focus Indicators with Sufficient Contrast ```css /* Focus ring must have 3:1 contrast against adjacent colors */ :focus-visible { outline: 2px solid oklch(45% 0.2 264); outline-offset: 2px; /* The 2px offset creates a gap, so contrast is measured against the background */ } /* High-contrast focus ring for dark backgrounds */ .dark-section :focus-visible { outline: 2px solid oklch(80% 0.15 264); outline-offset: 2px; } ``` ### Link Contrast ```css /* Links in body text need 3:1 contrast against surrounding text (WCAG 1.4.1) OR a non-color visual indicator (underline) */ /* Option 1: Underlined links (recommended — color alone is not enough) */ a { color: oklch(45% 0.2 264); text-decoration: underline; } /* Option 2: If removing underline, ensure 3:1 contrast with body text AND add non-color indicator on hover/focus */ a { color: oklch(45% 0.2 264); /* Must be 3:1 against body text color */ text-decoration: none; } a:hover, a:focus { text-decoration: underline; /* Non-color indicator */ } ``` ### Color Should Not Be the Only Indicator ```css /* WRONG: Only color differentiates error state */ .input-error { border-color: red; } /* CORRECT: Color plus additional visual indicator */ .input-error { border-color: oklch(55% 0.22 25); border-width: 2px; /* Thicker border */ box-shadow: 0 0 0 1px oklch(55% 0.22 25); /* Additional visual cue */ } ``` ```html Name is required ``` ### Testing Contrast with OKLCH Using OKLCH lightness as a rough contrast predictor: ```css :root { /* Rule of thumb: ~45-50 OKLCH lightness units between bg and text roughly corresponds to WCAG AA 4.5:1 contrast */ --bg-light: oklch(97% 0.005 264); /* L: 97% */ --text-on-light: oklch(25% 0.02 264); /* L: 25% — delta: 72% ✓ */ --bg-dark: oklch(15% 0.01 264); /* L: 15% */ --text-on-dark: oklch(90% 0.01 264); /* L: 90% — delta: 75% ✓ */ /* Muted text needs extra care */ --text-muted-light: oklch(45% 0.02 264); /* L: 45% — delta from bg: 52% ✓ */ --text-muted-dark: oklch(65% 0.01 264); /* L: 65% — delta from bg: 50% ✓ */ } ``` Note: OKLCH lightness delta is an approximation, not a substitute for actual contrast ratio testing. Always verify with a contrast checker tool. ### System-Level High Contrast Support ```css /* Respect Windows High Contrast / forced-colors mode */ @media (forced-colors: active) { .button { border: 2px solid ButtonText; /* Browser enforces system colors — don't fight it */ } .icon { fill: ButtonText; /* Use system color keywords */ } } ``` ## Common AI Mistakes - Using light gray text (`#999`, `#aaa`, `#bbb`) on white backgrounds — these all fail WCAG AA (4.5:1) - Setting placeholder text to a light gray that fails contrast, then using the placeholder as the only label - Generating colored buttons where the text-on-button contrast is insufficient (e.g., white text on a light yellow button) - Using color as the only means of conveying information — error states shown only in red, links distinguished only by color - Not testing contrast of interactive states: hover, focus, and active colors must also meet contrast ratios - Applying `opacity` to text for visual hierarchy instead of using lower-contrast colors — opacity reduces contrast unpredictably depending on the background - Assuming "dark mode = accessible" — dark mode needs its own contrast validation, and many dark themes fail contrast requirements - Using `color: inherit` or `currentColor` without verifying the inherited value provides sufficient contrast in every context - Not considering non-text contrast requirements (3:1) for borders, icons, and interactive element boundaries - Ignoring that WCAG contrast ratios apply to the actual rendered colors, including transparency — a semi-transparent text layer on a varying background may pass in some areas and fail in others ## When to Use Contrast checking should happen for **every text and UI element** in the design: - **Body text**: Must meet 4.5:1 against its background (AA) or 7:1 (AAA) - **Large headings** (24px+ or 18.67px+ bold): Must meet 3:1 (AA) or 4.5:1 (AAA) - **Interactive controls**: Borders, icons, and focus indicators must meet 3:1 against adjacent colors - **Links in text**: Must have 3:1 contrast against surrounding body text, or use a non-color indicator like underline - **Form labels and help text**: Must meet standard text contrast requirements - **Placeholder text**: Must meet 4.5:1 if it conveys required information (better: always use visible labels) ### Recommended testing tools - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) — quick manual check - [OKLCH Color Picker](https://oklch.com/) — visual contrast preview with OKLCH values - Chrome DevTools — element inspection shows contrast ratio for text - Lighthouse — automated audit flags contrast failures - [Colour Contrast Analyser (CCA)](https://www.tpgi.com/color-contrast-checker/) — desktop app with eyedropper ## References - [MDN: Color contrast — Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Perceivable/Color_contrast) - [WebAIM: Contrast and Color Accessibility](https://webaim.org/articles/contrast/) - [WCAG 2.2: Success Criterion 1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum) - [WCAG 2.2: Success Criterion 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) - [Color Contrast Accessibility: Complete WCAG 2025 Guide — AllAccessible](https://www.allaccessible.org/blog/color-contrast-accessibility-wcag-guide-2025) - [3 color contrast mistakes designers still make — UX Collective](https://uxdesign.cc/3-color-contrast-mistakes-designers-still-make-68cc224735b3) --- # CSS 3D Transforms > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/css-3d-transforms ## The Problem Developers attempt 3D effects like card flips, rotating panels, and cubes, then hit mysterious bugs: the backside of the card shows through as a mirror image, the rotation looks flat instead of 3D, or the entire effect collapses into a 2D plane. These bugs are confusing because each individual property (`transform`, `backface-visibility`) seems correct in isolation. The root cause is that CSS 3D transforms require a **system** of four coordinated properties — `perspective`, `transform-style: preserve-3d`, `backface-visibility`, and `perspective-origin` — and omitting or misplacing any one of them breaks the illusion. ## The Solution CSS 3D transforms work as a four-property system where each property has a specific role: 1. **`perspective`** on the parent container establishes the 3D viewing distance 2. **`transform-style: preserve-3d`** on the rotating element tells children to render in shared 3D space 3. **`backface-visibility: hidden`** on the card faces hides the reverse side when rotated away 4. **`perspective-origin`** on the parent shifts the vanishing point All four must be present and placed on the correct elements. `perspective` and `perspective-origin` go on the **parent** (the viewing container). `transform-style` goes on the **element being rotated**. `backface-visibility` goes on the **individual faces** that should hide when facing away. ### Core Principles #### Perspective Creates Depth Without `perspective`, rotations happen in a flat 2D projection — a `rotateY(45deg)` just compresses the element horizontally like a `scaleX()`. Adding `perspective` on the parent container creates a virtual camera at a set distance, making nearer edges appear larger and further edges smaller, just like real-world foreshortening. Lower values (200–400px) create dramatic, exaggerated depth. Higher values (800–1200px) produce subtle, natural-looking 3D. A good default for card effects is `perspective: 1000px`. #### preserve-3d Keeps Children in 3D Space By default, CSS flattens transformed children back into the parent's 2D plane — this is `transform-style: flat`. When building a card with a front face and a back face, both faces must exist in the same 3D space. Setting `transform-style: preserve-3d` on their shared parent (the card wrapper) prevents this flattening, so a child rotated `180deg` actually sits behind its sibling instead of overlapping on the same plane. #### backface-visibility Hides the Reverse Every HTML element has a front face and a back face. By default, the back face is visible — it renders as a mirror image of the front. For a card flip, this means both the front and back content show through simultaneously. Setting `backface-visibility: hidden` on each card face makes it invisible when rotated past 90 degrees, so only the face pointing toward the viewer is shown. #### perspective-origin Shifts the Vanishing Point `perspective-origin` controls where the viewer's eye is positioned relative to the parent container. The default is `50% 50%` (center). Shifting it to `top left` makes elements in the top-left corner appear closer while bottom-right elements recede further. This is useful for grids of tilted cards where you want an off-center viewing angle. ## Live Previews ### Card Flip on Hover The classic card flip uses all four properties together. The parent provides `perspective`, the card wrapper uses `preserve-3d` and rotates on hover, and each face uses `backface-visibility: hidden` to show only when facing forward. Front Side Hover to flip this card Back Side Here is the hidden content `} css={` .card-flip { perspective: 1000px; width: 260px; height: 200px; margin: 40px auto; font-family: system-ui, sans-serif; } .card-flip__inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; } .card-flip:hover .card-flip__inner, .card-flip:focus-within .card-flip__inner { transform: rotateY(180deg); } .card-flip__front, .card-flip__back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 12px; padding: 24px; } .card-flip__front { background: hsl(220deg 90% 56%); color: hsl(0deg 0% 100%); } .card-flip__back { background: hsl(160deg 70% 40%); color: hsl(0deg 0% 100%); transform: rotateY(180deg); } .card-flip__front h3, .card-flip__back h3 { font-size: 20px; font-weight: 700; margin: 0 0 8px; } .card-flip__front p, .card-flip__back p { font-size: 14px; opacity: 0.9; margin: 0; } `} /> ### Perspective Comparison Lower perspective values create extreme foreshortening while higher values produce a subtler effect. Both panels below have the same `rotateY(40deg)` transform — only the parent `perspective` value differs. perspective: 200px Hello perspective: 1000px Hello `} css={` .perspective-demo { display: flex; gap: 40px; justify-content: center; align-items: center; padding: 32px 20px; font-family: system-ui, sans-serif; height: 100%; } .perspective-demo__group { text-align: center; } .perspective-demo__label { font-size: 13px; font-weight: 600; color: hsl(220deg 20% 40%); margin: 0 0 16px; font-family: monospace; } .perspective-demo__stage--low { perspective: 200px; } .perspective-demo__stage--high { perspective: 1000px; } .perspective-demo__box { width: 140px; height: 140px; background: hsl(260deg 70% 60%); color: hsl(0deg 0% 100%); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; border-radius: 12px; transform: rotateY(40deg); } `} /> ### 3D Cube A CSS cube uses six faces positioned with `translateZ` and `rotateX`/`rotateY`. The parent provides `perspective` and the cube wrapper uses `preserve-3d`. Hover to spin the cube. Front Back Right Left Top Bottom `} css={` .cube-scene { perspective: 600px; width: 150px; height: 150px; margin: 80px auto; font-family: system-ui, sans-serif; } .cube { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transform: rotateX(-20deg) rotateY(-30deg); transition: transform 1s ease; } .cube-scene:hover .cube { transform: rotateX(-20deg) rotateY(150deg); } .cube__face { position: absolute; width: 150px; height: 150px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 700; color: hsl(0deg 0% 100%); border: 2px solid hsl(0deg 0% 100% / 0.3); border-radius: 4px; } .cube__face--front { background: hsl(220deg 80% 55% / 0.9); transform: translateZ(75px); } .cube__face--back { background: hsl(220deg 80% 55% / 0.9); transform: rotateY(180deg) translateZ(75px); } .cube__face--right { background: hsl(160deg 65% 45% / 0.9); transform: rotateY(90deg) translateZ(75px); } .cube__face--left { background: hsl(160deg 65% 45% / 0.9); transform: rotateY(-90deg) translateZ(75px); } .cube__face--top { background: hsl(40deg 80% 55% / 0.9); transform: rotateX(90deg) translateZ(75px); } .cube__face--bottom { background: hsl(40deg 80% 55% / 0.9); transform: rotateX(-90deg) translateZ(75px); } `} /> ### Perspective Origin `perspective-origin` shifts where the viewer's eye sits. Below, the same grid of tilted cards is viewed from three different vantage points. Hover over each group to see how the tilted cards are foreshortened differently. top left 1 2 3 4 center (default) 1 2 3 4 bottom right 1 2 3 4 `} css={` .origin-demo { display: flex; gap: 24px; justify-content: center; padding: 24px 16px; font-family: system-ui, sans-serif; height: 100%; align-items: flex-start; } .origin-demo__group { text-align: center; flex: 1; max-width: 180px; } .origin-demo__label { font-size: 12px; font-weight: 600; color: hsl(220deg 20% 40%); margin: 0 0 12px; font-family: monospace; } .origin-demo__stage { perspective: 400px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .origin-demo__stage--top-left { perspective-origin: top left; } .origin-demo__stage--center { perspective-origin: center; } .origin-demo__stage--bottom-right { perspective-origin: bottom right; } .origin-demo__card { width: 100%; aspect-ratio: 1; background: hsl(340deg 75% 55%); color: hsl(0deg 0% 100%); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; border-radius: 8px; transform: rotateY(30deg) rotateX(10deg); transition: transform 0.4s ease; } .origin-demo__stage:hover .origin-demo__card { transform: rotateY(0deg) rotateX(0deg); } `} /> ### Flip Card with Reduced Motion A production-ready card flip should respect `prefers-reduced-motion`. When the user prefers reduced motion, the card cross-fades between front and back using opacity instead of rotating in 3D. Accessible Flip Hover to reveal the back Back Content Uses cross-fade when motion is reduced `} css={` .a11y-flip { perspective: 1000px; width: 280px; height: 200px; margin: 40px auto; font-family: system-ui, sans-serif; } .a11y-flip__inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; } .a11y-flip:hover .a11y-flip__inner, .a11y-flip:focus-within .a11y-flip__inner { transform: rotateY(180deg); } .a11y-flip__front, .a11y-flip__back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 12px; padding: 24px; } .a11y-flip__front { background: hsl(250deg 65% 55%); color: hsl(0deg 0% 100%); } .a11y-flip__back { background: hsl(20deg 85% 55%); color: hsl(0deg 0% 100%); transform: rotateY(180deg); } .a11y-flip__front h3, .a11y-flip__back h3 { font-size: 20px; font-weight: 700; margin: 0 0 8px; } .a11y-flip__front p, .a11y-flip__back p { font-size: 14px; opacity: 0.9; margin: 0; } /* Reduced motion: cross-fade instead of 3D rotation */ @media (prefers-reduced-motion: reduce) { .a11y-flip { perspective: none; } .a11y-flip__inner { transform-style: flat; transition: none; } .a11y-flip:hover .a11y-flip__inner, .a11y-flip:focus-within .a11y-flip__inner { transform: none; } .a11y-flip__front, .a11y-flip__back { backface-visibility: visible; transition: opacity 0.3s ease; } .a11y-flip__front { opacity: 1; } .a11y-flip__back { opacity: 0; transform: none; } .a11y-flip:hover .a11y-flip__front, .a11y-flip:focus-within .a11y-flip__front { opacity: 0; } .a11y-flip:hover .a11y-flip__back, .a11y-flip:focus-within .a11y-flip__back { opacity: 1; } } `} /> ## Common AI Mistakes - **Missing `perspective` on the parent** — Applying `perspective` as part of the `transform` shorthand on the element itself instead of as a standalone property on its parent. The `perspective()` function in `transform` only affects that single element, not its children. - **Forgetting `transform-style: preserve-3d`** — Without this, child elements are flattened into the parent's 2D plane. A card flip looks correct in structure but both faces render on the same plane, overlapping instead of rotating. - **Placing `backface-visibility` on the wrong element** — It must go on the individual faces, not the rotating wrapper. On the wrapper, it hides the entire card when it turns away rather than selectively hiding each face. - **Not pre-rotating the back face** — The back face needs `transform: rotateY(180deg)` in its resting state so that it faces away from the viewer initially and comes into view when the wrapper rotates. - **Using `overflow: hidden` on a `preserve-3d` container** — `overflow: hidden` forces `transform-style: flat`, silently breaking the 3D effect. If clipping is needed, apply it on an outer wrapper that does not use `preserve-3d`. - **Ignoring `prefers-reduced-motion`** — 3D rotations can cause motion sickness. Production card flips should fall back to opacity cross-fades when the user prefers reduced motion. ## When to Use - **Card flips** — Product cards, flashcards, profile cards that reveal extra information on hover or click - **3D showcases** — Rotating cubes or prisms for image galleries or feature highlights - **Perspective tilts** — Hover effects that tilt cards slightly toward the cursor for a tactile feel - **Hero sections** — Dramatic 3D entrance animations on scroll with large perspective values ## Gotchas - `perspective` and `perspective-origin` must be on the **parent**, not the rotating element - `overflow: hidden` on a `preserve-3d` element silently reverts to `transform-style: flat` - `backface-visibility: hidden` has no effect without `transform-style: preserve-3d` on an ancestor - Nested `preserve-3d` elements compound perspective, which can produce unexpected distortion - Mobile Safari historically had bugs with `preserve-3d` — test on real iOS devices ## References - [perspective — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/perspective) - [transform-style — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-style) - [backface-visibility — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/backface-visibility) - [Intro to CSS 3D Transforms — David DeSandro](https://3dtransforms.desandro.com/) --- # Color Palette Strategy > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/color-palette-strategy ## The Problem AI agents often generate colors in isolation — picking a primary blue here, an accent green there — with no systematic relationship between them. The result is a palette that feels arbitrary: colors clash, fail to harmonize, and have no predictable structure. Without a palette strategy, designs end up with too many colors, no clear hierarchy, and missing semantic roles (no danger state, no muted text color). When combined with HSL's perceptual inconsistency, AI-generated palettes frequently look wrong even when the theory sounds right. ## The Solution A palette strategy works across three layers: 1. **Color harmony** — which hues to use and how they relate on the color wheel 2. **Semantic architecture** — assigning roles (primary, neutral, feedback) to color groups 3. **Perceptual consistency** — using OKLCH so shades look visually as intended ### Color Harmony Strategies Color harmony defines which hues work together and why. The five primary strategies: - **Monochromatic** — One hue at varied lightness and chroma. Safe, cohesive, never clashes. - **Complementary** — Two hues opposite on the color wheel (≈180° apart). High contrast and energy; use one as dominant, one as accent only. - **Analogous** — Three or more adjacent hues (±30° each). Natural and soothing — great for backgrounds with subtle variation. - **Triadic** — Three hues equally spaced (120° apart). Vibrant and balanced, but difficult to control — keep chroma low on two of the three. - **Split-complementary** — One hue plus the two adjacent to its complement (±30° from the opposite). The visual tension of complementary without the jarring clash. Monochromatic One hue (264°), varied lightness Complementary Blue (264°) + Yellow-green (84°) — 180° apart Analogous Adjacent hues 234°→306° (teal → blue → purple) Triadic Blue (264°) + Red-orange (24°) + Green (144°) — 120° each Split-complementary Blue (264°) + Yellow (54°) + Lime (114°) — softer than complementary `} css={`.harmonies { padding: 1.25rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 0.85rem; } .harmony { display: flex; align-items: center; gap: 0.75rem; } .harmony__name { font-size: 0.75rem; font-weight: 700; color: oklch(30% 0 0); width: 130px; flex-shrink: 0; } .harmony__swatches { display: flex; gap: 3px; flex: 0 0 auto; } .swatch { width: 36px; height: 36px; border-radius: 6px; } .swatch--gap { background: transparent; width: 12px; border-radius: 0; } .harmony__desc { font-size: 0.72rem; color: oklch(52% 0 0); }`} /> ### Building a Design Color Palette A complete palette has four layers. #### Primary, Secondary, and Accent The primary color carries your brand and drives all interactive elements (links, buttons, focus rings). Secondary supports the primary in areas like sidebars and section headers. Accent highlights the most important moments — calls to action, badges, notifications — and should represent ≤10% of the visual area. #### Neutral/Gray Scale Neutrals carry most of your UI: backgrounds, surfaces, borders, and text. Use 8–10 steps from near-white to near-black. Slightly tinted neutrals (low chroma in the brand hue direction) feel more refined than pure grays. #### Feedback Colors Every UI needs semantic colors for system states: - **Success** — green (≈hue 150) - **Warning** — amber (≈hue 65) - **Danger/Error** — red (≈hue 25) - **Info** — blue (≈hue 220) Keep their lightness consistent so they feel like they belong to the same system. #### The 60-30-10 Rule A classic proportion from interior design that translates directly to UI: - **60%** — dominant neutral (backgrounds, surfaces, most of the page) - **30%** — secondary supporting color (sidebars, headers, secondary actions) - **10%** — accent (CTAs, highlights, key interactions) Brand Dashboard Projects Team Reports Dashboard + New Project 2,847 Visitors 94% Satisfaction 12 Active 60% neutral — backgrounds & content 30% secondary — sidebar & header 10% accent — CTA only `} css={`:root { --neutral-bg: oklch(97% 0.005 264); --neutral-surface: oklch(100% 0 0); --neutral-border: oklch(88% 0.01 264); --neutral-text: oklch(22% 0.02 264); --neutral-text-muted: oklch(52% 0.015 264); --secondary: oklch(32% 0.08 264); --secondary-text: oklch(90% 0.04 264); --secondary-muted: oklch(50% 0.06 264); --accent: oklch(68% 0.22 55); --accent-text: oklch(20% 0.05 55); } .page-layout { display: flex; height: 100%; font-family: system-ui, sans-serif; background: var(--neutral-bg); } .page-layout__sidebar { width: 140px; background: var(--secondary); padding: 1rem 0.75rem; display: flex; flex-direction: column; gap: 1.25rem; flex-shrink: 0; } .sidebar-brand { font-size: 0.9rem; font-weight: 800; color: var(--secondary-text); letter-spacing: 0.05em; } .sidebar-nav { display: flex; flex-direction: column; gap: 0.2rem; } .sidebar-nav__item { font-size: 0.78rem; color: var(--secondary-muted); padding: 0.35rem 0.5rem; border-radius: 5px; cursor: pointer; text-decoration: none; } .sidebar-nav__item--active { background: oklch(50% 0.1 264 / 0.4); color: var(--secondary-text); } .page-layout__main { flex: 1; padding: 1rem 1.25rem; overflow: auto; display: flex; flex-direction: column; gap: 0.75rem; } .page-layout__header { display: flex; align-items: center; justify-content: space-between; } .page-layout__title { font-size: 1rem; font-weight: 700; color: var(--neutral-text); margin: 0; } .cta-btn { background: var(--accent); color: var(--accent-text); border: none; border-radius: 6px; padding: 0.4rem 0.75rem; font-size: 0.78rem; font-weight: 600; cursor: pointer; } .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } .card { background: var(--neutral-surface); border: 1px solid var(--neutral-border); border-radius: 8px; padding: 0.75rem; } .card__value { font-size: 1.4rem; font-weight: 700; color: var(--neutral-text); } .card__label { font-size: 0.72rem; color: var(--neutral-text-muted); margin-top: 0.15rem; } .legend { display: flex; flex-direction: column; gap: 0.25rem; margin-top: auto; padding-top: 0.5rem; border-top: 1px solid var(--neutral-border); } .legend__item { font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 4px; } .legend__item--60 { background: oklch(88% 0.01 264); color: oklch(35% 0 0); } .legend__item--30 { background: oklch(32% 0.08 264); color: oklch(90% 0 0); } .legend__item--10 { background: oklch(68% 0.22 55); color: oklch(20% 0.05 55); }`} /> ## Code Examples ### Monochromatic Palette in Practice A single hue at different lightnesses provides enough visual variety for a complete interface. Use the darkest shades for text, midrange for interactive elements, and light shades for backgrounds and subtle accents. TK Takazudo Design Engineer Active 48 Commits 7 PRs 12 Reviews View Profile Message `} css={`:root { --h: 264; --blue-50: oklch(96% 0.04 var(--h)); --blue-100: oklch(90% 0.07 var(--h)); --blue-200: oklch(80% 0.1 var(--h)); --blue-400: oklch(65% 0.16 var(--h)); --blue-600: oklch(50% 0.2 var(--h)); --blue-700: oklch(40% 0.18 var(--h)); --blue-900: oklch(25% 0.1 var(--h)); } .mono-ui { background: var(--blue-50); padding: 2rem; font-family: system-ui, sans-serif; height: 100%; box-sizing: border-box; display: flex; align-items: center; justify-content: center; } .mono-card { background: oklch(100% 0 0); border: 1px solid var(--blue-100); border-radius: 12px; padding: 1.25rem; width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 1rem; box-shadow: 0 2px 12px oklch(50% 0.1 264 / 0.1); } .mono-card__header { display: flex; align-items: center; gap: 0.75rem; } .mono-card__avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--blue-600); color: oklch(97% 0.02 264); display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 700; flex-shrink: 0; } .mono-card__name { font-size: 0.95rem; font-weight: 700; color: var(--blue-900); } .mono-card__role { font-size: 0.78rem; color: var(--blue-400); } .mono-card__badge { margin-left: auto; background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-200); font-size: 0.7rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 20px; } .mono-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; background: var(--blue-50); border-radius: 8px; padding: 0.75rem; } .mono-stat__value { font-size: 1.3rem; font-weight: 700; color: var(--blue-700); } .mono-stat__label { font-size: 0.7rem; color: var(--blue-400); } .mono-card__actions { display: flex; gap: 0.5rem; } .mono-btn { flex: 1; padding: 0.5rem; border-radius: 7px; font-size: 0.8rem; font-weight: 600; cursor: pointer; border: none; } .mono-btn--primary { background: var(--blue-600); color: oklch(97% 0.02 264); } .mono-btn--ghost { background: transparent; color: var(--blue-600); border: 1.5px solid var(--blue-200); }`} /> ### Complementary Color Scheme Use the complement as a high-contrast accent — it naturally draws the eye. Reserve it for the single most important action on the page. New Feature Advanced Analytics Get deeper insights with real-time dashboards, custom reports, and automated anomaly detection. Available on Pro plans. Upgrade to Pro Learn more `} css={`:root { --primary: oklch(50% 0.2 264); --primary-dark: oklch(35% 0.18 264); --primary-light: oklch(93% 0.05 264); --primary-text: oklch(97% 0.02 264); --accent: oklch(68% 0.22 55); --accent-hover: oklch(62% 0.24 55); --accent-text: oklch(20% 0.08 55); } .comp-ui { background: var(--primary-light); padding: 2rem; font-family: system-ui, sans-serif; height: 100%; box-sizing: border-box; display: flex; align-items: center; justify-content: center; } .comp-card { background: oklch(100% 0 0); border-radius: 14px; overflow: hidden; max-width: 380px; width: 100%; box-shadow: 0 4px 20px oklch(50% 0.1 264 / 0.12); } .comp-card__header { background: var(--primary); padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; } .comp-card__tag { background: oklch(60% 0.25 55); color: oklch(20% 0.08 55); font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; padding: 0.2rem 0.5rem; border-radius: 4px; width: fit-content; } .comp-card__title { font-size: 1.2rem; font-weight: 700; color: var(--primary-text); margin: 0; } .comp-card__body { font-size: 0.82rem; color: oklch(85% 0.05 264); margin: 0; line-height: 1.5; } .comp-card__footer { padding: 1rem 1.5rem; display: flex; gap: 0.75rem; align-items: center; } .comp-btn { border: none; border-radius: 8px; padding: 0.55rem 1rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; } .comp-btn--accent { background: var(--accent); color: var(--accent-text); } .comp-btn--ghost { background: transparent; color: var(--primary); text-decoration: underline; padding-left: 0; }`} /> ### Complete Design Token Palette A well-structured token set defines every color role before any component is built. This example uses OKLCH to keep consistent perceived lightness across all hue groups. For a full architecture around organizing these tokens into palette, theme, and component layers, see [Three-Tier Color Strategy](../three-tier-color-strategy). ```css :root { /* Primary — blue, oklch hue 264 */ --color-primary-50: oklch(96% 0.04 264); --color-primary-100: oklch(90% 0.07 264); --color-primary-500: oklch(55% 0.2 264); --color-primary-700: oklch(38% 0.17 264); --color-primary-900: oklch(22% 0.1 264); /* Neutral — near-achromatic, hue-tinted */ --color-neutral-50: oklch(98% 0.005 264); --color-neutral-200: oklch(88% 0.01 264); --color-neutral-500: oklch(55% 0.01 264); --color-neutral-700: oklch(38% 0.015 264); --color-neutral-900: oklch(18% 0.015 264); /* Feedback — same lightness (58%) across all hues */ --color-success: oklch(58% 0.18 150); /* green */ --color-warning: oklch(58% 0.18 65); /* amber */ --color-danger: oklch(58% 0.2 25); /* red */ --color-info: oklch(58% 0.18 220); /* blue */ /* Semantic aliases — what components use */ --color-bg: var(--color-neutral-50); --color-surface: oklch(100% 0 0); --color-border: var(--color-neutral-200); --color-text: var(--color-neutral-900); --color-text-muted: var(--color-neutral-500); --color-interactive: var(--color-primary-500); --color-interactive-fg: oklch(98% 0.01 264); } ``` Primary 50 100 200 300 500 700 900 Neutral 50 100 200 300 500 700 900 Feedback — same lightness across hues Success light Success Warning light Warning Danger light Danger Info light Info `} css={`.token-palette { padding: 1.25rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.1rem; background: oklch(97% 0.005 264); height: 100%; box-sizing: border-box; } .palette-group__label { font-size: 0.72rem; font-weight: 700; color: oklch(40% 0 0); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.4rem; } .palette-group__swatches { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; height: 52px; } .palette-group__swatches--feedback { grid-template-columns: repeat(8, 1fr); } .token-swatch { border-radius: 5px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; } .token-swatch--feedback { border-radius: 5px; } .token-swatch__label { font-size: 0.58rem; font-weight: 600; color: oklch(30% 0 0); } .token-swatch__label--light { color: oklch(95% 0 0); }`} /> ### Using OKLCH for Systematic Palette Generation The key advantage of OKLCH: fix lightness and chroma, rotate hue — every resulting color has the same perceived brightness. This is what makes systematic multi-color palettes possible. ```css /* Generate a categorical palette where all colors feel equally prominent */ :root { --palette-l: 62%; --palette-c: 0.18; --cat-red: oklch(var(--palette-l) var(--palette-c) 25); --cat-orange: oklch(var(--palette-l) var(--palette-c) 60); --cat-yellow: oklch(var(--palette-l) var(--palette-c) 90); --cat-green: oklch(var(--palette-l) var(--palette-c) 150); --cat-cyan: oklch(var(--palette-l) var(--palette-c) 200); --cat-blue: oklch(var(--palette-l) var(--palette-c) 264); --cat-purple: oklch(var(--palette-l) var(--palette-c) 310); } /* Generate a lightness scale for a single brand hue */ :root { --brand-h: 264; --brand-c: 0.18; --brand-50: oklch(96% calc(var(--brand-c) * 0.3) var(--brand-h)); --brand-100: oklch(90% calc(var(--brand-c) * 0.5) var(--brand-h)); --brand-300: oklch(74% calc(var(--brand-c) * 0.8) var(--brand-h)); --brand-500: oklch(55% var(--brand-c) var(--brand-h)); --brand-700: oklch(40% calc(var(--brand-c) * 0.9) var(--brand-h)); --brand-900: oklch(24% calc(var(--brand-c) * 0.7) var(--brand-h)); } ``` ## Color Tools and Resources These tools are essential for building, testing, and refining color palettes: - **[Adobe Color](https://color.adobe.com/)** — Interactive color wheel with all harmony rules, plus an accessibility checker that flags insufficient contrast ratios. - **[OKLCH Color Picker](https://oklch.com/)** — Pick and adjust colors directly in OKLCH space, with gamut visualization. Essential for building OKLCH palettes. - **[Tailwind CSS Colors](https://tailwindcss.com/docs/colors)** — A carefully crafted 10-step scale for 22 hues. An excellent reference for how a professional lightness scale is structured. - **[Coolors](https://coolors.co/)** — Fast palette generator with harmony rule suggestions, contrast checker, and export options. - **[Realtime Colors](https://www.realtimecolors.com/)** — Preview your palette on a real website layout instead of abstract swatches. Helps identify issues before building. - **[Vercel Geist Colors](https://vercel.com/geist/colors)** — Vercel's design system palette: a clean example of a production-grade neutral + semantic token system. - **[Huetone](https://huetone.ardov.me/)** — APCA-based palette builder that creates perceptually balanced lightness scales with built-in contrast checking. - **[ColorSlurp](https://colorslurp.com/)** — Desktop color picker with OKLCH support and palette management. ## Common AI Mistakes - Generating colors one at a time without establishing harmonic relationships — the result is a collection, not a palette - Using HSL to create "consistent" lightness scales — `hsl(60, 100%, 50%)` and `hsl(240, 100%, 50%)` are nowhere near the same perceived brightness - Choosing a complementary accent and using it at full saturation for 40% of the UI — complementary colors work only as accents (≤10%) - Omitting feedback colors (success, warning, danger, info) from the palette — components then have no token to reach for and hard-coded one-off colors appear - Neutral grays with zero chroma — pure `oklch(n% 0 0)` neutrals feel lifeless; slight tinting toward the brand hue grounds them in the design - Defining 40 one-off color values in components instead of 15 semantic tokens — creates maintenance nightmares when the brand color changes - Treating the palette as fixed — a good token system lets you change the entire brand hue by updating `--brand-h` and watching all tokens update automatically ## When to Use - **Design system setup** — define the full palette as tokens before writing any component - **Multi-hue UIs** — use harmony rules (analogous, triadic) to choose hues that don't conflict - **Data visualization** — categorical palettes at the same OKLCH lightness prevent one color from dominating - **Dark mode** — a well-structured token layer (semantic aliases over raw values) makes dark mode a palette swap, not a full rewrite - **Accessible color selection** — keep lightness delta between text and background ≥45% in OKLCH for reliable contrast ### When a full palette strategy is overkill - Single-page landing sites with one brand color and no complex UI states - Quick prototypes where tokens add overhead without benefit - Email templates where OKLCH support is limited — stick to hex and test widely ## References - [Adobe Color — color wheel and harmony tool](https://color.adobe.com/) - [OKLCH Color Picker](https://oklch.com/) - [Tailwind CSS default color palette](https://tailwindcss.com/docs/colors) - [Realtime Colors — preview palette on a live layout](https://www.realtimecolors.com/) - [Coolors — palette generator](https://coolors.co/) - [Huetone — APCA palette builder](https://huetone.ardov.me/) - [Vercel Geist Design System — Colors](https://vercel.com/geist/colors) - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH in CSS: why we moved from RGB and HSL — Evil Martians](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) - [Color theory for designers — Smashing Magazine](https://www.smashingmagazine.com/2010/01/color-theory-for-designers-part-1-the-meaning-of-color/) --- # @property > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/at-property ## The Problem Standard CSS custom properties are untyped — the browser treats them as arbitrary strings. This means you cannot animate or transition custom properties (e.g., smoothly transitioning a gradient), the browser cannot validate their values, and inheritance behavior cannot be controlled. AI agents rarely use `@property` and instead resort to JavaScript animations or complex workarounds for effects that `@property` makes trivial. ## The Solution The `@property` at-rule lets you formally register a CSS custom property with a type (`syntax`), an initial value, and inheritance control. Once a property is typed, the browser can interpolate it — enabling smooth transitions and animations on values that were previously impossible to animate, like gradients, colors within gradients, and individual numeric values. ## Code Examples ### Basic `@property` Declaration ```css @property --primary-color { syntax: ""; inherits: true; initial-value: #2563eb; } @property --card-radius { syntax: ""; inherits: false; initial-value: 8px; } @property --opacity-level { syntax: ""; inherits: false; initial-value: 1; } ``` ### Animating Gradients Without `@property`, gradient transitions snap between states. With typed properties, smooth interpolation becomes possible. ```css @property --gradient-start { syntax: ""; inherits: false; initial-value: #3b82f6; } @property --gradient-end { syntax: ""; inherits: false; initial-value: #8b5cf6; } .hero-banner { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); transition: --gradient-start 0.6s, --gradient-end 0.6s; } .hero-banner:hover { --gradient-start: #ec4899; --gradient-end: #f59e0b; } ``` ### Animated Gradient Angle ```css @property --angle { syntax: ""; inherits: false; initial-value: 0deg; } .rotating-gradient { background: linear-gradient(var(--angle), #3b82f6, #8b5cf6); animation: rotate-gradient 3s linear infinite; } @keyframes rotate-gradient { to { --angle: 360deg; } } ``` ### Progress Indicator with Animated Percentage ```css @property --progress { syntax: ""; inherits: false; initial-value: 0%; } .progress-ring { background: conic-gradient( #2563eb var(--progress), #e5e7eb var(--progress) ); border-radius: 50%; transition: --progress 1s ease-out; } .progress-ring[data-value="75"] { --progress: 75%; } ``` ### Type-Safe Design Tokens ```css @property --spacing-unit { syntax: ""; inherits: true; initial-value: 0.25rem; } @property --brand-hue { syntax: ""; inherits: true; initial-value: 220; } .design-system { --spacing-unit: 0.25rem; --brand-hue: 220; } .card { padding: calc(var(--spacing-unit) * 4); background: hsl(var(--brand-hue) 90% 95%); border: 1px solid hsl(var(--brand-hue) 60% 70%); } ``` ### Controlling Inheritance ```css @property --section-bg { syntax: ""; inherits: false; /* Does NOT cascade to children */ initial-value: transparent; } .section { --section-bg: #f0f9ff; background: var(--section-bg); } /* Nested sections get transparent (initial-value), not the parent's blue */ .section .section { /* --section-bg is transparent here because inherits: false */ background: var(--section-bg); } ``` ### Supported Syntax Types ```css /* All supported syntax descriptors */ @property --a { syntax: ""; inherits: false; initial-value: black; } @property --b { syntax: ""; inherits: false; initial-value: 0px; } @property --c { syntax: ""; inherits: false; initial-value: 0%; } @property --d { syntax: ""; inherits: false; initial-value: 0; } @property --e { syntax: ""; inherits: false; initial-value: 0; } @property --f { syntax: ""; inherits: false; initial-value: 0deg; } @property --g { syntax: ""; inherits: false; initial-value: 0s; } @property --h { syntax: ""; inherits: false; initial-value: 1dppx; } @property --i { syntax: ""; inherits: false; initial-value: 0px; } @property --j { syntax: ""; inherits: false; initial-value: url(); } @property --k { syntax: ""; inherits: false; initial-value: scale(1); } @property --l { syntax: ""; inherits: false; initial-value: none; } /* Union types */ @property --m { syntax: " | "; inherits: false; initial-value: black; } /* Universal (any value, like regular custom properties) */ @property --n { syntax: "*"; inherits: true; } ``` ## Browser Support - Chrome 85+ - Edge 85+ - Firefox 128+ - Safari 15.4+ Global support exceeds 93%. Firefox added support in mid-2024, making `@property` available in all major browsers. The feature is Baseline Newly available as of July 2024. ## Common AI Mistakes - Not using `@property` when trying to animate gradients or custom property values — resulting in abrupt snapping instead of smooth transitions - Using JavaScript-based animation (requestAnimationFrame) for effects that typed custom properties handle natively - Forgetting that all three descriptors (`syntax`, `inherits`, `initial-value`) are required (except `initial-value` when `syntax` is `*`) - Setting `inherits: true` when the property should be component-scoped, causing unintended cascade leakage - Not knowing that `@property` enables gradient animations — this is the most impactful and most overlooked use case - Confusing `@property` registration with the JavaScript `CSS.registerProperty()` API — the CSS at-rule is preferred for static definitions ## When to Use - Smooth gradient transitions and animations (the primary killer use case) - Animated progress indicators, loading spinners, and visual effects using conic/radial gradients - Type-safe design tokens where the browser should validate property values - Controlling inheritance to prevent custom properties from cascading into nested components - Any animation that requires interpolating a custom property value ## Live Previews Hover me Without @property, gradient transitions snap instantly. With typed properties, colors interpolate smoothly. `} css={` @property --gradient-start { syntax: ""; inherits: false; initial-value: #3b82f6; } @property --gradient-end { syntax: ""; inherits: false; initial-value: #8b5cf6; } .gradient-box { font-family: system-ui, sans-serif; background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); transition: --gradient-start 0.6s ease, --gradient-end 0.6s ease; border-radius: 16px; padding: 3rem 2rem; display: flex; align-items: center; justify-content: center; cursor: pointer; } .gradient-box:hover { --gradient-start: #ec4899; --gradient-end: #f59e0b; } .gradient-box span { color: white; font-size: 1.5rem; font-weight: 700; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} /> The --angle property is typed as <angle>, enabling smooth continuous rotation via @keyframes `} css={` @property --angle { syntax: ""; inherits: false; initial-value: 0deg; } .rotating-gradient { width: 200px; height: 200px; margin: 0 auto; border-radius: 50%; background: conic-gradient(from var(--angle), #3b82f6, #8b5cf6, #ec4899, #f59e0b, #3b82f6); animation: rotate-gradient 3s linear infinite; box-shadow: 0 4px 24px rgba(59, 130, 246, 0.3); } @keyframes rotate-gradient { to { --angle: 360deg; } } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} height={300} /> ## References - [@property - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@property) - [@property: Next-gen CSS variables now with universal browser support - web.dev](https://web.dev/blog/at-property-baseline) - [@property: giving superpowers to CSS variables - web.dev](https://web.dev/at-property) - [Providing Type Definitions for CSS with @property - Modern CSS Solutions](https://moderncss.dev/providing-type-definitions-for-css-with-at-property/) - [@property - CSS-Tricks](https://css-tricks.com/almanac/rules/p/property/) --- # Three-Tier Color Strategy > Source: https://takazudomodular.com/pj/zcss/docs/styling/color/three-tier-color-strategy ## The Problem When building a website or web app, it's tempting to use color values directly in component CSS — picking a hex code for a button, a different shade for a sidebar, and yet another for hover states. This works initially but creates two serious problems: 1. **Changing the brand color requires hunting through every component** — there's no single place to update 2. **No semantic meaning** — seeing `#89b4fa` in a button tells you nothing about *why* that color was chosen or what role it plays A common first improvement is defining a palette of CSS custom properties (`--color-blue-500`, `--color-red-400`). But even with a palette, components end up tightly coupled to specific palette colors. If the design system decides that "primary interactive elements" should shift from blue to indigo, you're back to searching every component. ## The Solution Organize colors into **three tiers**, each with a clear purpose: | Tier | Name | Purpose | Example | |------|------|---------|---------| | 1 | **Palette** | Raw color values — the full set of available colors | `--palette-blue-500` | | 2 | **Theme** | Semantic roles — what each color *means* in the design | `--theme-fg`, `--theme-accent` | | 3 | **Component** | Scoped overrides — colors specific to one component | `--_button-shadow`, `--_card-highlight` | The key insight: **each tier only references the tier above it**. Components use theme tokens. Theme tokens point to palette colors. Palette holds the actual values. ## Code Examples ### Tier 1: The Palette The palette is the raw material — every color available in the system. These are not used directly in components. Think of them as paint tubes: you have them ready, but you don't squeeze them onto the canvas without a plan. Palette (Tier 1) These are the raw colors. Components should not use these directly. Blue Red Gray Green `} css={` :root { /* Tier 1: Palette — raw color values */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-blue-900: oklch(28% 0.1 250); --palette-red-100: oklch(92% 0.05 25); --palette-red-300: oklch(72% 0.16 25); --palette-red-500: oklch(58% 0.22 25); --palette-red-700: oklch(42% 0.18 25); --palette-red-900: oklch(28% 0.12 25); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-700: oklch(40% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); --palette-green-100: oklch(94% 0.06 150); --palette-green-300: oklch(76% 0.14 150); --palette-green-500: oklch(58% 0.18 150); --palette-green-700: oklch(42% 0.14 150); --palette-green-900: oklch(28% 0.1 150); } .palette-demo { padding: 1.25rem; font-family: system-ui, sans-serif; background: oklch(99% 0 0); height: 100%; box-sizing: border-box; } .palette-demo__title { margin: 0 0 0.25rem; font-size: 0.85rem; font-weight: 700; color: oklch(25% 0 0); } .palette-demo__desc { margin: 0 0 0.75rem; font-size: 0.72rem; color: oklch(50% 0 0); } .palette-demo__groups { display: flex; flex-direction: column; gap: 0.5rem; } .palette-demo__group { display: flex; align-items: center; gap: 0.6rem; } .palette-demo__label { font-size: 0.7rem; font-weight: 600; color: oklch(40% 0 0); width: 40px; flex-shrink: 0; } .palette-demo__swatches { display: flex; gap: 3px; } .swatch { width: 40px; height: 32px; border-radius: 5px; }`} /> ### Tier 2: The Theme Theme tokens give **semantic meaning** to palette colors. Instead of "blue-500", components see "accent color" or "foreground color". This is the layer that makes redesigns painless — change `--theme-accent` from blue to indigo in one place, and every component updates. Palette → Theme Mapping → --theme-fg → --theme-bg → --theme-accent → --theme-accent-subtle → --theme-muted → --theme-border → --theme-error → --theme-success Result: UI using Theme tokens App Dashboard Status Active Errors 3 View Details `} css={` :root { --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); /* Tier 2: Theme — semantic pointers to palette */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-100); --theme-accent: var(--palette-blue-500); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-error: var(--palette-red-500); --theme-success: var(--palette-green-500); } .theme-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: oklch(99% 0 0); height: 100%; box-sizing: border-box; } .theme-demo__title { margin: 0 0 0.5rem; font-size: 0.72rem; font-weight: 700; color: oklch(30% 0 0); text-transform: uppercase; letter-spacing: 0.04em; } .mapping { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } .mapping__from { width: 22px; height: 22px; border-radius: 4px; flex-shrink: 0; } .mapping__arrow { font-size: 0.75rem; color: oklch(60% 0 0); } .mapping__to-name { font-size: 0.75rem; font-family: monospace; color: oklch(35% 0 0); font-weight: 600; } .mini-ui { background: var(--theme-bg); border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; } .mini-ui__header { background: var(--theme-fg); color: var(--theme-bg); padding: 0.4rem 0.6rem; display: flex; align-items: center; gap: 0.75rem; } .mini-ui__logo { font-size: 0.75rem; font-weight: 700; } .mini-ui__nav-item { font-size: 0.75rem; color: oklch(70% 0.01 264); } .mini-ui__body { padding: 0.6rem; display: flex; flex-direction: column; gap: 0.4rem; } .mini-ui__card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 6px; padding: 0.4rem 0.6rem; display: flex; justify-content: space-between; align-items: center; } .mini-ui__card-title { font-size: 0.75rem; color: var(--theme-muted); } .mini-ui__card-value { font-size: 0.75rem; font-weight: 700; } .mini-ui__card-value--success { color: var(--theme-success); } .mini-ui__card-value--error { color: var(--theme-error); } .mini-ui__btn { background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.4rem; font-size: 0.7rem; font-weight: 600; cursor: pointer; }`} /> ### Tier 3: Component-Scoped Colors Sometimes a component needs colors that don't fit into the global theme — a shadow, a product variant color, or a subtle gradient stop. These are **Tier 3** variables: narrowly scoped, defined on the component itself, and referencing either theme or palette tokens. Use a leading underscore (`--_`) for component-scoped custom properties to signal local scope: ```css .product-card { --_card-variant: var(--palette-blue-500); --_card-shadow: oklch(58% 0.2 250 / 0.25); } ``` The `--_` prefix tells readers "this variable is locally scoped to this component" — similar to how `_privateMethod` signals private scope in other languages. This is not a CSS rule — it is a project-level naming convention. In Tailwind + component-first projects (React, Vue, Astro with utility classes), Tier 3 component-scoped CSS custom properties are rarely needed. The component framework itself provides scoping — there are no separate CSS files where these variables would be defined. Tier 3 is primarily relevant for general CSS approaches (BEM, CSS Modules, vanilla CSS). Ocean Wave Runner Waterproof wireless speaker $79 Sunset Wave Runner Waterproof wireless speaker $79 Forest Wave Runner Waterproof wireless speaker $79 `} css={` :root { --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); --theme-fg: var(--palette-gray-900); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-accent: var(--palette-blue-500); } /* Tier 3: Component-scoped colors */ .product-card { /* Default variant colors — referencing palette */ --_card-variant: var(--palette-blue-500); --_card-variant-light: var(--palette-blue-300); --_card-variant-dark: var(--palette-blue-700); /* Shadow uses palette with transparency */ --_card-shadow: oklch(58% 0.2 250 / 0.25); background: oklch(100% 0 0); border: 1px solid var(--theme-border); border-radius: 12px; overflow: hidden; box-shadow: 0 4px 16px var(--_card-shadow); width: 160px; flex-shrink: 0; } /* Product color variants — only Tier 3 vars change */ .product-card--ocean { --_card-variant: oklch(58% 0.15 230); --_card-variant-light: oklch(80% 0.08 230); --_card-variant-dark: oklch(40% 0.12 230); --_card-shadow: oklch(58% 0.15 230 / 0.25); } .product-card--sunset { --_card-variant: oklch(62% 0.2 50); --_card-variant-light: oklch(82% 0.1 50); --_card-variant-dark: oklch(42% 0.15 50); --_card-shadow: oklch(62% 0.2 50 / 0.25); } .product-card--forest { --_card-variant: oklch(55% 0.15 150); --_card-variant-light: oklch(78% 0.08 150); --_card-variant-dark: oklch(38% 0.12 150); --_card-shadow: oklch(55% 0.15 150 / 0.25); } .product-card__badge { background: linear-gradient(135deg, var(--_card-variant), var(--_card-variant-dark)); color: oklch(97% 0.01 0); padding: 0.75rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } .product-card__body { padding: 0.75rem; } .product-card__name { margin: 0 0 0.2rem; font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .product-card__desc { margin: 0 0 0.5rem; font-size: 0.75rem; color: var(--theme-muted); line-height: 1.4; } .product-card__price { font-size: 0.9rem; font-weight: 700; color: var(--_card-variant-dark); } .tier3-demo { display: flex; gap: 0.75rem; padding: 1.5rem; font-family: system-ui, sans-serif; background: var(--palette-gray-100); height: 100%; box-sizing: border-box; align-items: flex-start; justify-content: center; }`} /> ### All Three Tiers Working Together Here's a complete example showing how the three tiers connect. Notice how easy it is to trace any color back to its source: component uses theme, theme points to palette. Tier 1: Palette --palette-blue-500: oklch(58% 0.2 250); --palette-gray-900: oklch(22% ...); ↓ Tier 2: Theme --theme-accent: var(--palette-blue-500); --theme-fg: var(--palette-gray-900); ↓ Tier 3: Component .btn { background: var(--theme-accent); } .btn { --_btn-shadow: ...; } MyApp Home Settings ✓ Changes saved successfully Welcome back Your project has 3 pending tasks. View Tasks Dismiss `} css={` :root { /* ===== Tier 1: Palette ===== */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-green-100: oklch(94% 0.06 150); --palette-green-500: oklch(58% 0.18 150); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-800: oklch(30% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); /* ===== Tier 2: Theme ===== */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-success: var(--palette-green-500); --theme-success-bg: var(--palette-green-100); } /* ===== Tier 3: Component ===== */ .demo-btn--primary { --_btn-shadow: oklch(58% 0.2 250 / 0.3); } .full-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: oklch(97% 0.005 264); height: 100%; box-sizing: border-box; } .full-demo__code { display: flex; flex-direction: column; justify-content: center; gap: 0.2rem; } .code-block { background: var(--palette-gray-800); border-radius: 6px; padding: 0.5rem 0.6rem; display: flex; flex-direction: column; gap: 0.15rem; } .code-block__label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: oklch(70% 0.15 250); margin-bottom: 0.15rem; } .code-block code { font-size: 0.75rem; color: oklch(85% 0.02 264); font-family: monospace; white-space: nowrap; } .code-block__arrow { text-align: center; font-size: 0.85rem; color: oklch(50% 0.1 250); line-height: 1; } .full-demo__result { display: flex; align-items: center; } .full-demo__ui { background: var(--theme-bg); border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; width: 100%; } .demo-header { background: var(--theme-fg); padding: 0.45rem 0.75rem; display: flex; align-items: center; gap: 1rem; } .demo-header__brand { color: var(--theme-accent-fg); font-size: 0.8rem; font-weight: 700; } .demo-header__nav { display: flex; gap: 0.5rem; } .demo-header__link { color: oklch(70% 0.01 264); font-size: 0.75rem; cursor: pointer; } .demo-header__link--active { color: oklch(90% 0.04 250); } .demo-content { padding: 0.6rem; display: flex; flex-direction: column; gap: 0.5rem; } .demo-alert { display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.75rem; } .demo-alert--success { background: var(--theme-success-bg); color: var(--theme-success); } .demo-alert__icon { font-weight: 700; } .demo-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 8px; padding: 0.7rem; } .demo-card__title { margin: 0 0 0.2rem; font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .demo-card__text { margin: 0 0 0.6rem; font-size: 0.7rem; color: var(--theme-muted); } .demo-card__actions { display: flex; gap: 0.4rem; } .demo-btn { padding: 0.35rem 0.7rem; border-radius: 6px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: none; } .demo-btn--primary { background: var(--theme-accent); color: var(--theme-accent-fg); box-shadow: 0 2px 8px var(--_btn-shadow); } .demo-btn--outline { background: transparent; color: var(--theme-accent); border: 1.5px solid var(--theme-border); }`} /> ### The Power of Tier 2: Swapping Themes The biggest advantage of the three-tier system is visible when you change the theme. By remapping Tier 2 pointers, every component updates without touching component CSS. Theme: Corporate Monthly Report Updated 2 hours ago Open Theme: Creative Monthly Report Updated 2 hours ago Open Theme: Night Monthly Report Updated 2 hours ago Open `} css={` :root { /* Tier 1: Palette (same for all themes) */ --palette-blue-500: oklch(58% 0.2 250); --palette-orange-500: oklch(65% 0.2 55); --palette-purple-500: oklch(55% 0.22 300); } .swap-demo { display: grid; grid-template-columns: repeat(3, 1fr); height: 100%; font-family: system-ui, sans-serif; } /* Tier 2: Theme variations — same component, different pointers */ .swap-col--blue { --theme-bg: oklch(96% 0.005 250); --theme-surface: oklch(100% 0 0); --theme-fg: oklch(22% 0.015 250); --theme-muted: oklch(55% 0.01 250); --theme-accent: var(--palette-blue-500); --theme-accent-fg: oklch(98% 0.01 250); --theme-border: oklch(85% 0.01 250); } .swap-col--warm { --theme-bg: oklch(96% 0.01 55); --theme-surface: oklch(100% 0 0); --theme-fg: oklch(25% 0.02 55); --theme-muted: oklch(55% 0.015 55); --theme-accent: var(--palette-orange-500); --theme-accent-fg: oklch(20% 0.05 55); --theme-border: oklch(85% 0.015 55); } .swap-col--dark { --theme-bg: oklch(18% 0.015 300); --theme-surface: oklch(24% 0.02 300); --theme-fg: oklch(90% 0.01 300); --theme-muted: oklch(60% 0.01 300); --theme-accent: var(--palette-purple-500); --theme-accent-fg: oklch(95% 0.01 300); --theme-border: oklch(32% 0.02 300); } .swap-col { background: var(--theme-bg); padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .swap-col__label { margin: 0; font-size: 0.7rem; font-weight: 700; color: var(--theme-muted); text-transform: uppercase; letter-spacing: 0.05em; } .swap-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 10px; padding: 0.85rem; display: flex; flex-direction: column; gap: 0.35rem; } .swap-card__title { font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .swap-card__meta { font-size: 0.75rem; color: var(--theme-muted); } .swap-btn { margin-top: 0.4rem; background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.4rem 0.75rem; font-size: 0.72rem; font-weight: 600; cursor: pointer; align-self: flex-start; }`} /> ### Complete CSS Code Structure Here's how a real project would organize the three tiers across files: ```css /* ===== tokens/palette.css — Tier 1 ===== */ :root { --palette-blue-100: oklch(92% 0.06 250); --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-blue-900: oklch(28% 0.1 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-amber-500: oklch(62% 0.18 65); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-700: oklch(40% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); } /* ===== tokens/theme.css — Tier 2 ===== */ :root { /* Layout */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-border: var(--palette-gray-300); --theme-muted: var(--palette-gray-500); /* Interactive */ --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); /* Feedback */ --theme-error: var(--palette-red-500); --theme-success: var(--palette-green-500); --theme-warning: var(--palette-amber-500); } /* ===== components/button.css — Tier 3 ===== */ .btn { /* Component-specific color derived from theme. Relative color syntax (oklch(from ...)) — Baseline 2024. For wider support, use a hardcoded fallback. */ --_btn-shadow: oklch(from var(--theme-accent) l c h / 0.3); background: var(--theme-accent); color: var(--theme-accent-fg); box-shadow: 0 2px 8px var(--_btn-shadow); } .btn:hover { background: var(--theme-accent-hover); } /* ===== components/product-card.css — Tier 3 ===== */ .product-card { /* Colors unique to this component, not in theme */ --_card-variant: var(--palette-blue-500); --_card-glow: oklch(from var(--_card-variant) l c h / 0.15); } .product-card--sunset { --_card-variant: oklch(62% 0.2 50); } ``` ### Tailwind CSS: Three Tiers with Custom Theme Config The three-tier strategy maps naturally to Tailwind's configuration. Palette colors go in `theme.colors`, theme tokens become CSS custom properties that Tailwind classes reference. tailwind.config = { theme: { extend: { colors: { // Tier 2: Theme tokens as Tailwind colors // These reference CSS vars set on :root 'theme-fg': 'var(--theme-fg)', 'theme-bg': 'var(--theme-bg)', 'theme-surface': 'var(--theme-surface)', 'theme-accent': 'var(--theme-accent)', 'theme-accent-hover': 'var(--theme-accent-hover)', 'theme-accent-fg': 'var(--theme-accent-fg)', 'theme-muted': 'var(--theme-muted)', 'theme-border': 'var(--theme-border)', 'theme-success': 'var(--theme-success)', 'theme-error': 'var(--theme-error)', } } } } :root { /* Tier 1: Palette */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); /* Tier 2: Theme — pointers to palette */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-success: var(--palette-green-500); --theme-error: var(--palette-red-500); } MyApp Dashboard Settings Welcome back Your project has 3 pending tasks and 1 alert. 12 Projects 98% Uptime 3 Alerts View Tasks Dismiss All colors use --theme-* tokens — swap the palette and everything updates. `} /> ### Tailwind: Theme Switching with Tier 2 The same Tailwind markup works with completely different color schemes — just change the CSS variables. tailwind.config = { theme: { extend: { colors: { 'theme-fg': 'var(--theme-fg)', 'theme-bg': 'var(--theme-bg)', 'theme-surface': 'var(--theme-surface)', 'theme-accent': 'var(--theme-accent)', 'theme-accent-fg': 'var(--theme-accent-fg)', 'theme-muted': 'var(--theme-muted)', 'theme-border': 'var(--theme-border)', } } } } .theme-corporate { --theme-fg: oklch(22% 0.015 250); --theme-bg: oklch(96% 0.005 250); --theme-surface: oklch(100% 0 0); --theme-accent: oklch(58% 0.2 250); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: oklch(55% 0.01 250); --theme-border: oklch(85% 0.01 250); } .theme-warm { --theme-fg: oklch(25% 0.02 55); --theme-bg: oklch(96% 0.01 55); --theme-surface: oklch(100% 0 0); --theme-accent: oklch(65% 0.2 55); --theme-accent-fg: oklch(20% 0.05 55); --theme-muted: oklch(55% 0.015 55); --theme-border: oklch(85% 0.015 55); } .theme-night { --theme-fg: oklch(90% 0.01 300); --theme-bg: oklch(18% 0.015 300); --theme-surface: oklch(24% 0.02 300); --theme-accent: oklch(65% 0.22 300); --theme-accent-fg: oklch(95% 0.01 300); --theme-muted: oklch(55% 0.01 300); --theme-border: oklch(32% 0.02 300); } Corporate Dashboard 3 tasks pending 94% Score View Creative Dashboard 3 tasks pending 94% Score View Night Dashboard 3 tasks pending 94% Score View `} /> ### Dark Mode: Just Another Tier 2 Mapping Dark mode fits naturally into the three-tier system. The palette (Tier 1) stays the same — you already have all the colors. Dark mode is simply an alternative Tier 2 mapping that picks different palette values for the same semantic tokens. The CSS `light-dark()` function makes this especially clean by co-locating both values in a single declaration. For a deep dive into dark mode techniques, see [Dark Mode Strategies](./dark-mode-strategies). Light Mode Project Status All systems operational. Last deploy was 12 minutes ago. Healthy Details Dark Mode Project Status All systems operational. Last deploy was 12 minutes ago. Healthy Details `} css={` /* Tier 1: Palette — identical for both modes */ :root { --palette-blue-500: oklch(58% 0.2 250); --palette-blue-100: oklch(92% 0.06 250); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-800: oklch(30% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); --palette-green-500: oklch(58% 0.18 150); --palette-green-100: oklch(94% 0.06 150); --palette-green-900: oklch(28% 0.1 150); } /* Tier 2: Light theme — Tier 2 maps palette to semantic roles */ .dm-col--light { color-scheme: light; --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-fg: var(--palette-gray-900); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-accent: var(--palette-blue-500); --theme-accent-fg: oklch(98% 0.01 250); --theme-accent-subtle: var(--palette-blue-100); --theme-success: var(--palette-green-500); --theme-success-bg: var(--palette-green-100); --theme-success-fg: oklch(30% 0.08 150); } /* Tier 2: Dark theme — same tokens, different palette picks */ .dm-col--dark { color-scheme: dark; --theme-bg: oklch(15% 0.01 264); --theme-surface: oklch(20% 0.015 264); --theme-fg: oklch(92% 0.005 264); --theme-muted: oklch(60% 0.01 264); --theme-border: oklch(30% 0.015 264); --theme-accent: oklch(70% 0.17 250); --theme-accent-fg: oklch(15% 0.01 250); --theme-accent-subtle: oklch(25% 0.04 250); --theme-success: oklch(70% 0.15 150); --theme-success-bg: oklch(22% 0.04 150); --theme-success-fg: oklch(80% 0.1 150); } .dm-demo { display: grid; grid-template-columns: 1fr 1fr; height: 100%; font-family: system-ui, sans-serif; } .dm-col { background: var(--theme-bg); padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .dm-col__label { margin: 0; font-size: 0.75rem; font-weight: 700; color: var(--theme-muted); text-transform: uppercase; letter-spacing: 0.05em; } .dm-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 10px; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } .dm-card__title { font-size: 0.9rem; font-weight: 700; color: var(--theme-fg); } .dm-card__text { font-size: 0.8rem; color: var(--theme-muted); line-height: 1.5; margin: 0; } .dm-card__footer { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem; } .dm-badge { font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.6rem; border-radius: 999px; } .dm-badge--success { background: var(--theme-success-bg); color: var(--theme-success-fg); } .dm-btn { margin-left: auto; background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; }`} /> With `light-dark()`, the same Tier 2 mapping can be expressed without separate selectors: ```css /* Tier 2 with light-dark() — both modes in a single declaration */ :root { color-scheme: light dark; --theme-bg: light-dark(var(--palette-gray-50), oklch(15% 0.01 264)); --theme-surface: light-dark(oklch(100% 0 0), oklch(20% 0.015 264)); --theme-fg: light-dark(var(--palette-gray-900), oklch(92% 0.005 264)); --theme-muted: light-dark(var(--palette-gray-500), oklch(60% 0.01 264)); --theme-border: light-dark(var(--palette-gray-300), oklch(30% 0.015 264)); --theme-accent: light-dark(var(--palette-blue-500), oklch(70% 0.17 250)); --theme-accent-fg: light-dark(oklch(98% 0.01 250), oklch(15% 0.01 250)); } ``` Tier 1 and Tier 3 stay exactly the same — only Tier 2 changes. Components never know whether they're in light or dark mode. ## Common AI Mistakes - **Skipping Tier 2** — using palette colors directly in components (`color: var(--palette-blue-500)`) defeats the purpose; when the brand changes, every component must be updated - **Too many Tier 3 variables** — if a component defines 10+ local color variables, it's likely reinventing the theme layer; promote those to Tier 2 - **Not separating palette from theme** — defining `--primary: oklch(58% 0.2 250)` mixes raw values with semantic meaning, making it impossible to swap palettes - **Inconsistent naming** — mixing `--color-primary`, `--brand-blue`, and `--accent` at the same level creates confusion; use consistent prefixes per tier (`--palette-*`, `--theme-*`) - **Making Tier 1 too small** — a palette with only 5 colors forces components to invent their own raw values (Tier 3 colors with hardcoded values), breaking the system - **Hardcoding colors in Tailwind utilities** — using `bg-blue-500` instead of `bg-theme-accent` bypasses the theme layer entirely ## When to Use - **Any project with more than a few components** — the overhead of three tiers pays off as soon as you need consistency - **Multi-theme or white-label products** — Tier 2 makes theme switching trivial - **Design system or component library** — components should reference theme tokens, not raw colors - **Dark mode** — dark mode is just another Tier 2 mapping (see [Dark Mode Strategies](./dark-mode-strategies)) - **Gradual adoption** — you can start with Tier 1 + 2 and add Tier 3 as components need scoped colors ### When three tiers is overkill - Single-page sites with one brand color and no theme variations - Quick prototypes where speed matters more than maintainability - Static sites with minimal interactive elements ## Related Articles - [Color Palette Strategy](./color-palette-strategy) — How to choose and generate palette colors (Tier 1) - [Dark Mode Strategies](./dark-mode-strategies) — Dark mode techniques that work with Tier 2 token swapping - [Advanced Custom Properties](../../../methodology/design-systems/custom-properties-advanced/) — Fallback chains, space toggles, and component APIs that implement the three-tier pattern - [Theming Recipes](../../../methodology/design-systems/custom-properties-advanced/theming-recipes) — Production-ready theme recipes using this architecture - [Color Token Patterns](../../../methodology/design-systems/tight-token-strategy/color-tokens) — Applying three-tier tokens in Tailwind's `@theme` system - [Three-Tier Font-Size Strategy](../../typography/font-sizing/three-tier-font-size-strategy/) — The same three-tier architecture applied to font sizes ## References - [MDN: Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH Color Picker](https://oklch.com/) - [Design Tokens Format — W3C Community Group](https://design-tokens.github.io/community-group/format/) - [Tailwind CSS: Customizing Colors](https://tailwindcss.com/docs/colors) --- # Gradient Techniques > Source: https://takazudomodular.com/pj/zcss/docs/styling/effects/gradient-techniques ## The Problem AI agents tend to reach for flat solid colors when backgrounds, text fills, or decorative elements would look significantly more polished with gradients. When AI does use gradients, they often pick harsh color pairings, fail to use perceptual color spaces for smooth transitions, and miss advanced techniques like layered gradients, hard-stop patterns, or gradient text. ## The Solution CSS provides three gradient functions — `linear-gradient()`, `radial-gradient()`, and `conic-gradient()` — each with a repeating variant. These can be layered, combined with hard stops for patterns, applied to text, and interpolated in perceptual color spaces like `oklch` for smooth, vibrant results. ## Code Examples ### Linear Gradient Basics ```css /* Top-to-bottom (default) */ .gradient-basic { background: linear-gradient(#3b82f6, #8b5cf6); } /* Angled */ .gradient-angled { background: linear-gradient(135deg, #3b82f6, #8b5cf6); } /* Multi-stop with explicit positions */ .gradient-multi { background: linear-gradient( to right, #3b82f6 0%, #8b5cf6 50%, #ec4899 100% ); } ``` ### Smooth Gradients with oklch Standard RGB interpolation can produce muddy mid-tones. Using `oklch` produces smoother, more vibrant transitions. ```css /* Muddy middle in sRGB */ .gradient-srgb { background: linear-gradient(in srgb, #3b82f6, #ef4444); } /* Vibrant, smooth transition in oklch */ .gradient-oklch { background: linear-gradient(in oklch, #3b82f6, #ef4444); } /* oklch with explicit hue interpolation */ .gradient-oklch-longer { background: linear-gradient(in oklch longer hue, #3b82f6, #ef4444); } ``` ### Radial Gradients ```css /* Centered circle */ .radial-circle { background: radial-gradient(circle, #3b82f6, #1e3a5f); } /* Ellipse from top-left */ .radial-positioned { background: radial-gradient(ellipse at 20% 30%, #8b5cf6, #1e1b4b); } /* Spotlight effect */ .radial-spotlight { background: radial-gradient( circle at 50% 0%, hsl(220deg 80% 60%) 0%, hsl(220deg 80% 10%) 70% ); } ``` ### Conic Gradients ```css /* Color wheel */ .conic-wheel { background: conic-gradient(red, yellow, lime, aqua, blue, magenta, red); border-radius: 50%; } /* Pie chart segment */ .conic-pie { background: conic-gradient( #3b82f6 0deg 120deg, #8b5cf6 120deg 210deg, #e2e8f0 210deg 360deg ); border-radius: 50%; } ``` ### Hard-Stop Gradients for Patterns Hard stops occur when two color stops share the same position, creating an instant transition rather than a smooth blend. ```css /* Striped background */ .stripes { background: repeating-linear-gradient( 45deg, #3b82f6 0px, #3b82f6 10px, #2563eb 10px, #2563eb 20px ); } /* Checkerboard with conic-gradient */ .checkerboard { background: conic-gradient( #e2e8f0 25%, #fff 25% 50%, #e2e8f0 50% 75%, #fff 75% ); background-size: 40px 40px; } /* Progress bar with hard stop */ .progress-bar { background: linear-gradient( to right, #3b82f6 0%, #3b82f6 65%, #e2e8f0 65%, #e2e8f0 100% ); } ``` ### Layered Gradients for Complex Backgrounds Multiple gradients can be stacked using comma-separated `background` values. Later values render behind earlier ones, so use transparency to let lower layers show through. ```css /* Mesh-like layered gradient */ .layered-gradient { background: radial-gradient( circle at 20% 80%, hsl(220deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 80% 20%, hsl(330deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 50% 50%, hsl(270deg 80% 60% / 0.4), transparent 60% ), hsl(220deg 40% 10%); } /* Noise-like texture using layered gradients */ .texture-gradient { background: repeating-linear-gradient( 0deg, transparent, transparent 2px, hsl(0deg 0% 100% / 0.03) 2px, hsl(0deg 0% 100% / 0.03) 4px ), repeating-linear-gradient( 90deg, transparent, transparent 2px, hsl(0deg 0% 100% / 0.03) 2px, hsl(0deg 0% 100% / 0.03) 4px ), linear-gradient(135deg, #1a1a2e, #16213e); } ``` ### Gradient Text ```css .gradient-text { background: linear-gradient(135deg, #3b82f6, #8b5cf6); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; /* fallback for non-webkit */ } ``` ```html Gradient Heading ``` ### Gradient Border via Background-Clip ```css .gradient-border { border: 3px solid transparent; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box; } ``` ## Live Previews `} css={` .gradient-box { width: 100%; height: 100%; background: linear-gradient( 135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100% ); } `} height={200} /> `} css={` .radial-box { width: 100%; height: 100%; background: radial-gradient( circle at 30% 40%, hsl(280deg 80% 60%) 0%, transparent 50% ), radial-gradient( circle at 70% 60%, hsl(200deg 80% 60%) 0%, transparent 50% ), hsl(220deg 40% 15%); } `} height={200} /> `} css={` .conic-wrapper { display: flex; justify-content: center; align-items: center; height: 100%; background: #1a1a2e; } .conic-wheel { width: 180px; height: 180px; border-radius: 50%; background: conic-gradient( red, yellow, lime, aqua, blue, magenta, red ); } `} height={240} /> `} css={` .mesh { width: 100%; height: 100%; background: radial-gradient( circle at 20% 80%, hsl(220deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 80% 20%, hsl(330deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 50% 50%, hsl(270deg 80% 60% / 0.4), transparent 60% ), hsl(220deg 40% 10%); } `} height={250} /> Gradient Heading`} css={` .text-wrapper { display: flex; justify-content: center; align-items: center; height: 100%; background: #0f172a; } .gradient-text { font-family: system-ui, sans-serif; font-size: 48px; font-weight: 800; background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; margin: 0; } `} height={200} /> ## Deep Dive - [CSS-Only Pattern Library](./css-pattern-library) — A collection of CSS-only decorative patterns built entirely with gradients: checkerboards, stripes, dots, waves, and more ## Common AI Mistakes - **Flat solid colors everywhere** — Using `background: #3b82f6` when a subtle gradient like `linear-gradient(#3b82f6, #2563eb)` would add depth and polish. - **Harsh color pairings** — Picking two unrelated colors that produce muddy mid-tones in sRGB interpolation. Using `in oklch` resolves this. - **Ignoring repeating-gradient for patterns** — Manually creating stripe or pattern effects with pseudo-elements when `repeating-linear-gradient()` handles it natively. - **Forgetting the -webkit- prefix for gradient text** — `background-clip: text` still requires `-webkit-background-clip: text` in many browsers. - **Using only one gradient when layered gradients create richer effects** — A single linear-gradient is fine, but layering radial gradients over a base creates mesh-gradient-like complexity. - **Not setting background-size for repeating patterns** — `conic-gradient` patterns require explicit `background-size` to tile correctly. ## When to Use - **Subtle depth** — A near-identical two-color gradient on cards, buttons, or headers adds dimension without being flashy - **Hero sections and backgrounds** — Layered gradients create visually rich backgrounds without image downloads - **Text highlights** — Gradient text draws attention to headings or CTAs - **Decorative patterns** — Hard-stop repeating gradients for stripes, dots, and geometric backgrounds - **Data visualization** — Conic gradients for simple pie/donut charts without JavaScript - **Borders** — Gradient borders via `background-clip` for elements that need rounded corners ## Tailwind CSS Tailwind provides gradient utilities via `bg-gradient-to-*` for direction and `from-*`, `via-*`, `to-*` for color stops. These cover common linear gradient patterns concisely. ### Linear Gradient `} height={220} /> ### Gradient Cards Plan A Blue to purple Plan B Emerald to cyan Plan C Three-stop via `} height={220} /> ### Gradient Text Gradient Heading `} height={220} /> ## References - [Using CSS Gradients — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Images/Using_gradients) - [A Complete Guide to CSS Gradients — CSS-Tricks](https://css-tricks.com/a-complete-guide-to-css-gradients/) - [Make Beautiful Gradients in CSS — Josh W. Comeau](https://www.joshwcomeau.com/css/make-beautiful-gradients/) - [A Deep CSS Dive Into Radial and Conic Gradients — Smashing Magazine](https://www.smashingmagazine.com/2022/01/css-radial-conic-gradient/) --- # Claude > Source: https://takazudomodular.com/pj/zcss/docs/claude Claude Code configuration reference. ## Resources --- # ja-translator > Source: https://takazudomodular.com/pj/zcss/docs/claude-agents/ja-translator # Japanese Translator Agent English MDX documentation files in this project (zcss) を日本語に翻訳するエージェント。 ## Context This is a CSS best practices documentation site built with Astro 5. The docs use MDX format with: - YAML frontmatter (`sidebar_position`, `description`, etc.) - `CssPreview` / `TailwindPreview` components for live CSS demos - Code blocks (CSS, HTML) - Markdown tables, headings, lists Source English files live in `src/content/docs//`. Translated Japanese files go to `src/content/docs-ja//`. ## File Handling ### Input / Output paths - Source: `src/content/docs//.mdx` - Output: `src/content/docs-ja//.mdx` Preserve the exact directory structure and file name. If the category directory doesn't exist in the i18n path, create it. ### Workflow 1. Read the source English file 2. Create the i18n output directory if needed 3. Translate following the rules below 4. Write the translated file at the correct i18n path 5. Preserve all MDX component usage, frontmatter structure, and code blocks exactly ## Translation Rules ### What to translate - All prose and explanatory text → Japanese - Markdown headings (`##`, `###`, etc.) → Japanese - Table headers and cell content (explanatory text) → Japanese - `CssPreview` component's `title` prop value → Japanese - Frontmatter `description` field → Japanese (if present) - Inline comments explaining concepts → Japanese ### What to keep in English - Code blocks (CSS, HTML, JavaScript) — keep entirely in English - CSS property names, values, selectors, and class names in prose (e.g., `display: flex`, `.container`, `margin-inline`) - HTML element and attribute names in prose (e.g., ``, `class`) - `CssPreview` / `TailwindPreview` component props other than `title` (`html`, `css`, `height`) — keep as-is - Demo HTML text content inside CssPreview — keep in English (demos show CSS patterns; English text doesn't interfere) - Import statements — keep unchanged - Frontmatter `sidebar_position` — keep as-is - Reference links (MDN links, spec links, etc.) — keep URLs as-is - File names, paths, terminal commands — keep in English ### Technical terms On **first mention** in an article, provide the Japanese gloss followed by the English term in parentheses: - フレックスボックス(flexbox) - グリッド(grid) - 重ね合わせコンテキスト(stacking context) - 論理プロパティ(logical properties) - カスケードレイヤー(cascade layers) After the first mention, use the English term only (flexbox, grid, etc.). Well-known terms that can stay in English throughout without a gloss: - CSS, HTML, JavaScript - flexbox, grid (if the reader is expected to know these already) - margin, padding, border - hover, focus, transition, animation - viewport, media query, container query - rem, em, px, vw, vh Use your judgment: if the term is universally used in English by Japanese web developers, skip the gloss. ### Inline code references When referencing CSS properties or values inline, keep them in code backticks and in English. Surrounding Japanese text uses Japanese quotation marks 「」 only for non-code concept references. ```markdown `display: flex` を使って中央揃えを行います。 「重ね合わせコンテキスト」が新しく作られます。 `ディスプレイ: フレックス` を使います。 ``` ## Japanese Writing Style This is **technical documentation**, not a casual blog. Use a polite but direct style. ### Basic rules - Use **です/ます** polite form consistently for main text - Keep sentences concise — prefer shorter sentences over long compound ones - Use direct, clear Japanese — avoid overly formal language - Be objective in tone ### Sentence endings | Pattern | Usage | Example | | --- | --- | --- | | `〜です` | Basic statement | `flexbox は横並びレイアウトに適しています` | | `〜ます` | Action/explanation | `このプロパティを使います` | | `〜しましょう` | Recommendation | ``grid` を使いましょう` | | `〜してください` | Instruction | `値を指定してください` | | `〜ません` | Negation | `この方法は推奨されません` | | `〜でしょう` | Probability | `ほとんどの場合うまく動くでしょう` | ### Avoid these expressions - `〜でございます` / `〜させていただきます` — overly formal - `〜いただければ幸いです` — overly humble - Excessive exclamation marks or emphatic expressions - `ここが重要!` / `要注意!` — don't editorialize importance ### For "Common AI Mistakes" sections Use direct warnings: - `〜してはいけません` (must not) - `〜は避けましょう` (should avoid) - `〜しないでください` (do not) ### For "When to Use" / recommendation sections - `〜しましょう` for recommendations - `〜が適しています` for suitability - `〜を使います` for instructions ## Vocabulary Rules ### 漢字とひらがなの使い分け - **「言う」vs「いう」**: 発話の文脈でのみ漢字「言う」を使用。それ以外(「〜ということ」「〜というわけ」)はひらがな「いう」 - **「風に」**: 漢字で「風に」と表記。「ふうに」とひらがなで書かない ### General vocabulary - Don't use overly emphatic expressions - Keep an objective, instructional tone - Use consistent terminology throughout a single article - Prefer established Japanese web development terminology where it exists ## Section Title Translation Patterns Common section headings in this project and their Japanese translations: | English | Japanese | | --- | --- | | The Problem | 問題 | | The Solution | 解決方法 | | Code Examples | コード例 | | When to Use | 使い分け | | Common AI Mistakes | AIがよくやるミス | | Best Practices | ベストプラクティス | | Browser Support | ブラウザサポート | | Key Concepts | 主要な概念 | | Summary | まとめ | | Deep Dive | 詳細解説 | ## Example Translation ### English source ```mdx ## The Problem Centering elements is one of the most fundamental CSS tasks, yet AI agents frequently produce overcomplicated or inappropriate solutions. ``` ### Japanese translation ```mdx ## 問題 要素のセンタリングは CSS の最も基本的なタスクの一つですが、AI エージェントは複雑すぎる、 あるいは不適切な解決策を生成しがちです。 ``` ### CssPreview translation ```mdx ``` ## Quality Checklist Before finishing a translation, verify: - [ ] All prose text is in Japanese - [ ] All code blocks remain in English - [ ] CssPreview `title` props are translated - [ ] CssPreview `html`/`css` props are untouched - [ ] Import statements are unchanged - [ ] Frontmatter structure is preserved (`sidebar_position` unchanged) - [ ] Technical terms have Japanese gloss on first mention (where appropriate) - [ ] です/ます form is used consistently - [ ] File is placed at the correct i18n path - [ ] No mojibake or encoding issues --- # b4push > Source: https://takazudomodular.com/pj/zcss/docs/claude-skills/b4push # Before Push Quality Checks Run the project's quality gate before pushing. ## Command ```bash pnpm b4push ``` ## What It Checks 1. **Type checking** — `pnpm check` (astro sync + tsc --noEmit) 2. **Build** — `pnpm build` (full Astro production build) 3. **Link check** — `pnpm run check:links` (scan dist/ for broken internal links) ## When to Run - Before pushing to remote - After adding or moving documentation articles - After modifying internal links between articles ## Link Check Details The link checker (`scripts/check-links.js --strict`) scans built HTML in `dist/` for: - Broken internal links (href pointing to non-existent pages) - Absolute links in MDX source that bypass the base path (`/pj/zcss/`) Runs in strict mode — exits with error on any broken link. --- # l-demo-component > Source: https://takazudomodular.com/pj/zcss/docs/claude-skills/l-demo-component # Demo Component Usage Guide For CssPreview basics (props, rendering behavior, CSS conventions), see `doc/CLAUDE.md`. This skill covers **decision patterns** for effective demo usage. ## defaultOpen Prop Controls whether the code panel is expanded on first render. | Situation | defaultOpen | Rationale | | --- | --- | --- | | Explaining a concept — demo illustrates it | `false` (default) | Reader focuses on visual result first | | Showing code for confirmation | `true` | Reader needs code + result side by side | | Listing variety of visual patterns | `false` (default) | Visual comparison is primary goal | ## Demo Sizing Tips - Set `height` to avoid layout shift — estimate from content, typically 200-400px - For comparison demos, use side-by-side layout with `display: flex; gap: 32px` - For responsive demos, keep breakpoints under 768px so tablet/mobile presets show differences ## Media Query Remapping CssPreview iframe widths: Mobile=320px, Tablet=768px, Full=~900-1100px. If production code uses breakpoints like 1024px or 1280px, remap to smaller values (e.g., 500px, 700px) so the demo visually demonstrates the responsive behavior within the iframe. ## Multiple Demos Per Article Prefer **more demos over more prose**. Each distinct CSS behavior should have its own CssPreview. A good article has 3-6 demos with brief explanatory text between them. --- # l-handle-deep-article > Source: https://takazudomodular.com/pj/zcss/docs/claude-skills/l-handle-deep-article # Deep Article Handler Convert a flat `.mdx` article into a folder with `index.mdx` + sub-pages when the topic has enough depth. ## When to Use Use when a topic has: - Reference tables or catalogs too large for inline - Multiple distinct sub-patterns each deserving focused demos - Cheat sheet material users would browse independently - 10+ real-world recipes (card patterns, form patterns, animation recipes) Do **not** use for topics that fit in a single page with 4-5 demos. ## Conversion Steps ### 1. Create folder, move main article ```bash cd src/content/docs// mkdir my-topic mv my-topic.mdx my-topic/index.mdx ``` ### 2. Update `index.mdx` frontmatter Add a "Deep Dive" section at bottom: ```mdx ## Deep Dive - [Sub-page Title](./sub-page-1) - Brief description - [Sub-page Title](./sub-page-2) - Brief description ``` ### 3. Create sub-pages Each sub-page: standalone `.mdx` with `sidebar_position`, CssPreview demos, focused on one aspect. ```mdx --- sidebar_position: 1 --- # Sub-page Title (CssPreview demos and content) ``` ## Conventions - Main article keeps Problem/Solution/Demo structure - Sub-pages can be reference-oriented (tables, catalogs, recipes) - Follow all CSS/demo conventions from `CLAUDE.md` - File naming: kebab-case - After conversion, regenerate css-wisdom index: `pnpm run generate:css-wisdom` --- # l-translate > Source: https://takazudomodular.com/pj/zcss/docs/claude-skills/l-translate # Translate EN Docs to JA Translate English MDX documentation files to Japanese using the `ja-translator` subagent. ## Input Parsing Parse the argument to determine the mode: - **File path** (e.g., `docs/color/three-tier-color-strategy.mdx`) — translate that specific file - **Category** (e.g., `color`, `layout`) — translate all untranslated files in that category - **`check-missing`** or **`check`** — scan for all EN docs without JA counterparts - **No argument** — ask the user what to translate ## Path Convention - Source EN: `src/content/docs//.mdx` - Target JA: `src/content/docs-ja//.mdx` For deep articles (folder with `index.mdx` + sub-pages): - Source EN: `src/content/docs///index.mdx` - Target JA: `src/content/docs-ja///index.mdx` ## Mode A: Translate Specific Files ### Step 1: Identify Files Resolve the input to one or more source EN file paths. Verify each exists. ### Step 2: Check Existing JA Files For each source file, check if a JA translation already exists at the i18n path. - **If JA exists**: Read both EN and JA files. Compare to find sections that differ (the EN may have been updated after the JA was created). Report findings and ask the user whether to update the JA or skip. - **If JA does not exist**: Proceed to translation. ### Step 3: Translate via Subagent For each file that needs translation, use the `ja-translator` subagent: ``` Task tool → subagent_type: "ja-translator" prompt: "Translate to Japanese. Write the result to ." ``` **Run translations in parallel** when there are multiple independent files. For updates to existing JA files: ``` Task tool → subagent_type: "ja-translator" prompt: "Update the Japanese translation at based on changes in . Read both files, identify what changed in the EN source, and update only the corresponding sections in the JA file." ``` ### Step 4: Validate Run `pnpm build` to verify no broken links or MDX errors. ### Step 5: Report List all translated files and their output paths. ## Mode B: Check Missing Translations ### Step 1: Scan EN Docs List all `.mdx` files under `src/content/docs/` (recursively). ### Step 2: Compare with JA Docs For each EN file, check if a corresponding file exists at `src/content/docs-ja/`. ### Step 3: Report Output a table of missing JA translations grouped by category: ``` | Category | File | Status | |----------|------|--------| | color | three-tier-color-strategy.mdx | MISSING | | visual | gradient-techniques/index.mdx | EXISTS | ``` ### Step 4: Offer to Translate Ask the user if they want to translate the missing files (all at once or selectively). ## Mode C: Translate Category Same as Mode A but for all untranslated files in a given category directory. ### Step 1: List EN Files in Category ```bash ls src/content/docs// ``` ### Step 2: Filter to Missing Compare with `src/content/docs-ja//` and identify files without JA counterparts. ### Step 3: Translate All Missing Run parallel `ja-translator` subagent tasks for all missing files. ## Notes - The `ja-translator` agent handles all translation rules (what to translate vs keep in English, technical term glossing, writing style, etc.) - Always preserve the exact file name — only the directory path changes - CssPreview/TailwindPreview `title` props get translated; `html`/`css`/`height` props stay as-is - Code blocks stay entirely in English - After translation, the user should visually verify the JA page renders correctly --- # l-writing > Source: https://takazudomodular.com/pj/zcss/docs/claude-skills/l-writing # Writing & MDX Rules for zcss Articles Rules for writing CSS Best Practices articles. Follow these to keep every article consistent, direct, and useful for AI agents. ## Audience Primary readers are **AI agents** that consume these articles to learn CSS patterns. Human developers also read and maintain the docs. Write for both: precise enough for machines, clear enough for humans. ## Tone - Professional, educational, and direct - No filler words, unnecessary hedging, or conversational padding - Active voice over passive voice - Short sentences over long ones - State facts; do not editorialize ### OK/NG: Tone **OK (direct):** > Flexbox centering requires a defined height on the parent container. **NG (hedging, filler):** > It's worth noting that you might want to consider setting a defined height on the parent container when using flexbox centering. **OK (active voice):** > Use `margin-inline: auto` to center block elements horizontally. **NG (passive voice):** > Block elements can be horizontally centered by using `margin-inline: auto`. ## Article Structure Every article follows this section order. Not all sections are required, but the order must be preserved. 1. **The Problem** — common mistakes or misunderstandings. State what goes wrong and why it matters. 2. **The Solution** — high-level approach. Keep it brief — details belong in code examples. 3. **Code Examples** — CSS (and HTML when needed) with inline commentary. Each technique gets its own sub-heading. Follow each code block with a `CssPreview` demo. 4. **Live Previews** — group `CssPreview` components here if not already inline above. Prefer inline placement. 5. **Quick Reference** — summary table: "Scenario" and "Technique" columns. 6. **Common AI Mistakes** — specific mistakes AI agents make. Bold the mistake, then explain. 7. **When to Use** — decision guidance by technique. Sub-headings, one or two sentences each. 8. **Tailwind CSS** — `TailwindPreview` equivalents. Optional — only when direct utility counterparts exist. 9. **References** — external links to MDN, web.dev, CSS-Tricks, etc. ## CssPreview Demos Every CSS concept must have a corresponding `CssPreview` demo. Demos are the most valuable part of each article. Prefer more demos over more prose. - CssPreview renders in an iframe with no JavaScript — all interactions must be CSS-only - Use `hsl()` colors, not hex - Use descriptive BEM-ish CSS class names (e.g., `.card__title`, `.flex-center`) - Keep demos minimal — show only the technique being explained - Every demo needs a descriptive `title` prop ### OK/NG: Demo Colors **OK:** ```css background: hsl(210, 50%, 95%); color: hsl(210, 80%, 40%); ``` **NG:** ```css background: #f0f4f8; color: #2563eb; ``` ## Writing Rules ### Lead with Problems Start articles with what goes wrong, not with what works. AI agents learn better from "do not do X because Y" than from "here is how to do X." ### One Concept Per Article Each article covers one CSS technique or pattern. If the topic has enough depth, use the deep article pattern (folder with `index.mdx` + sub-pages). ### No Subjective Judgment Do not write "this is elegant" or "this is the best approach." State what the technique does, when to use it, and the trade-offs. **OK (factual):** > `place-items: center` is the most concise centering syntax — a single declaration replaces two. **NG (subjective):** > `place-items: center` is an elegant and beautiful solution to the centering problem. ### Do Not Repeat Generic CSS Knowledge Assume the reader knows basic CSS. Focus on patterns, trade-offs, and common mistakes specific to the topic. ### Use Tables for Comparisons When comparing multiple approaches, use tables instead of prose. Tables are easier for AI agents to parse. ### Code Before Prose Show the code first, then explain it. Readers (especially AI agents) can often understand the code without explanation. ## Deep Article Pattern When a topic warrants multiple sub-pages, convert the flat `.mdx` file into a folder: ``` docs/layout/centering-techniques/ index.mdx # Overview + navigation margin-auto.mdx # Sub-page flexbox.mdx # Sub-page grid.mdx # Sub-page ``` --- ## Markdown & MDX Formatting Rules ### File Naming Use **kebab-case** for all file names: ``` centering-techniques.mdx OK centeringTechniques.mdx NG Centering_Techniques.mdx NG ``` ### Frontmatter Every MDX file requires YAML frontmatter with at least `sidebar_position`: ```yaml --- sidebar_position: 3 --- ``` Category index pages use `sidebar_position: 0`. ### Import Patterns Place imports immediately after frontmatter, before any content: ```mdx ``` ### Headings - Article titles use `h1` (the `# Title` at the top). All sections use `h2` (`##`) or `h3` (`###`). - Do not skip heading levels — go from `h2` to `h3`, never `h2` to `h4`. - Every heading must be followed by content before the next heading. ### Lists - Do not mix ordered and unordered lists. - Do not put code blocks inside list items — place code blocks outside. ### Bold - Bold is for inline emphasis only — not as section headings or list item headers. ### Tables Use tables for comparisons and quick-reference content. Keep tables simple with two or three columns. ### Horizontal Rules Do not use `---` between sections. Headings provide sufficient visual separation. Use `---` only for major topic shifts (e.g., separating appendix content). ### Admonitions Use sparingly. No imports needed. ```markdown :::note[Optional Title] Supplementary information. ::: ``` Available types: `note`, `tip`, `info`, `warning`, `danger`. Use `note` and `info` for most cases. ### CssPreview in MDX ```mdx Centered `} css={` .flex-center { display: flex; justify-content: center; align-items: center; height: 200px; background: hsl(210, 50%, 97%); } .box { padding: 16px 24px; background: hsl(210, 80%, 55%); color: hsl(0, 0%, 100%); border-radius: 8px; font-family: system-ui, sans-serif; } `} /> ``` Rules: - Always provide a descriptive `title` prop - Use `hsl()` for all colors - Use descriptive BEM-ish class names - Keep HTML and CSS minimal - CSS-only interactions: `:hover`, `:focus`, `:checked`, `:target`, etc. - No JavaScript, no `` tags ## Related Skills - **`/css-wisdom`** — Before writing non-trivial CSS in demos or articles, invoke `/css-wisdom ` to look up best practices. This ensures demos and article content align with the project's CSS guidance.