CSS Architecture skill for Claude Code
Cascade layers instead of specificity wars. Claude measures a stylesheet before touching it with a bundled specificity report, then declares a layer order, moves vendor and reset CSS into low layers, flattens selectors to single classes, and replaces !important with layering. Components get container queries and logical properties, tokens become two-tier custom properties, and the report can gate CI on specificity limits.
- name
css-architecture - size220 lines · ≈2,050 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Structures modern CSS so it scales without specificity wars — cascade layers for ordering, :where() for zero-specificity defaults, custom properties as tokens, container queries for component-level responsiveness, :has() for parent and state selection, logical properties for RTL, native nesting, and a naming convention. Includes a stdlib script that reports high-specificity selectors and !important usage. Use when CSS overrides keep breaking, !important is spreading, components need to adapt to their container, or when organizing a stylesheet or design-system CSS from scratch. Try asking
- Our CSS is full of !important and overrides keep breaking — restructure it
- Make this card component adapt to the sidebar and the main column
- Set up the stylesheet structure for our new design system
Use it when
- Override bugs and !important are spreading through the codebase
- Components must respond to their container width, not the viewport
- Third-party CSS keeps beating your component styles
- Starting a global stylesheet or design-system CSS from scratch
Skip it when
- The project styles with Tailwind utilities — use tailwind-css
- You need breakpoint and grid strategy for whole pages — use responsive-layouts
How to check the result
Re-run the specificity script and confirm max specificity and !important counts dropped, then check key pages at mobile and desktop widths and in RTL.
The common mistake it prevents
Leaving some styles unlayered after introducing @layer, so those unlayered rules silently beat every layered component style.
Maintainable CSS has a declared order (layers), low and flat specificity (mostly one
class), values drawn from custom properties, components that respond to their
container rather than the viewport, and no !important outside utilities and
accessibility overrides.
Before changing anything
Measure the current state:
python3 scripts/css_specificity_report.py dist/assets/*.css --top 15
grep -rn "!important" src --include=*.css --include=*.scss | wc -l
grep -rn "@layer\|@container\|:has(" src --include=*.css | head
The script prints counts of ID selectors, !important, deep descendant chains, and the
highest-specificity selectors; it exits 1 when --max-specificity or
--max-important thresholds are exceeded, so it can gate CI. Run it on compiled CSS
when the source is Sass/Less. Nested rules are scored as written (approximate).
Also check browser targets (browserslist in package.json) before using newer features.
Workflow
- [ ] 1. Measure specificity and !important (script above)
- [ ] 2. Declare a layer order; move existing CSS into layers
- [ ] 3. Convert base/reset styles to :where() so anything can override them
- [ ] 4. Extract repeated raw values into custom properties (tokens)
- [ ] 5. Replace viewport media queries inside components with container queries
- [ ] 6. Switch physical properties to logical ones where direction matters
- [ ] 7. Remove !important and ID selectors made unnecessary by layers
- [ ] 8. Re-measure; visual-check key pages
Cascade layers
/* First line of the entry stylesheet: declares order once */
@layer reset, vendor, base, tokens, layout, components, utilities, overrides;
@import url("normalize.css") layer(reset);
@import url("datepicker.css") layer(vendor);
@layer base {
:where(h1, h2, h3) { line-height: 1.2; text-wrap: balance; }
:where(a) { color: var(--color-link); }
}
@layer components {
.card { padding: var(--space-4); border-radius: var(--radius-md); }
}
@layer utilities {
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
How layers resolve:
- Later layers beat earlier layers regardless of selector specificity. A single class
in
utilitiesbeats#id .a .bincomponents. - Unlayered styles beat all layered styles. Put everything in a layer, or leave only deliberate overrides unlayered.
!importantreverses layer order: an important declaration inresetbeats one inutilities. That is why!importantin early layers is dangerous and useful only for accessibility guarantees (e.g. reduced-motion resets).- Third-party CSS goes into a low layer so your components override it without specificity hacks.
Specificity rules
- Target: one class per selector (0,1,0); at most (0,2,0) for state or modifiers.
- No ID selectors for styling. No type selectors qualifying classes (
div.card). - Use
:where()to write zero-specificity defaults and:is()only when you want the highest argument’s specificity. - Descendant chains deeper than three compounds (
.nav ul li a span) couple CSS to markup; give the element a class instead. - State via attributes and pseudo-classes:
.tab[aria-selected="true"],.field:has(:invalid), rather than parallel.is-activeclasses that drift from real state.
Naming
Pick one and enforce it:
| Approach | When |
|---|---|
| CSS Modules / scoped styles (Vue, Svelte, Astro) | Component frameworks; local class names by default |
BEM (.card, .card__title, .card--featured) | Global CSS, server templates, multi-team stylesheets |
| Utility-first (Tailwind) | See tailwind-css |
@scope (.card) to (.card__content) { ... } | Donut scoping without naming conventions — check browser targets first |
Custom properties as tokens
@layer tokens {
:root {
--space-1: 0.25rem; --space-2: 0.5rem; --space-4: 1rem; --space-8: 2rem;
--radius-md: 0.5rem;
--color-surface: light-dark(#ffffff, #121417);
--color-text: light-dark(#1b1e24, #e8eaee);
--color-link: light-dark(oklch(0.5 0.18 260), oklch(0.78 0.12 260));
color-scheme: light dark;
}
}
/* Component-level API: expose knobs, default to tokens */
.button {
--button-bg: var(--color-accent);
background: var(--button-bg);
padding-block: var(--space-2);
padding-inline: var(--space-4);
}
.button--danger { --button-bg: var(--color-danger); }
- Two tiers: global tokens (
--color-*,--space-*) and component-scoped properties (--button-bg) that default to tokens. Override the component property, not the internals. - Register a property with
@propertywhen you need to animate it or want type checking and a guaranteed initial value:
@property --progress { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
- Custom properties do not work inside media query conditions; breakpoints stay literal.
Container queries
.product-card-slot { container: card / inline-size; }
.product-card { display: grid; gap: var(--space-2); }
@container card (min-width: 28rem) {
.product-card { grid-template-columns: 8rem 1fr; }
}
.product-card__title { font-size: clamp(1rem, 4cqi, 1.375rem); }
- Use container queries for components; keep viewport media queries for page layout.
- The container cannot size itself from its children in the queried axis; put
container-typeon a wrapper whose width comes from the layout. container-type: sizealso contains block size — usually you wantinline-size.
:has() patterns
.field:has(input:invalid:not(:placeholder-shown)) .field__error { display: block; }
.card:has(> img) { padding-block-start: 0; }
body:has(dialog[open]) { overflow: hidden; }
form:has(input[type="checkbox"]:checked) .bulk-actions { display: flex; }
Keep :has() arguments specific (> img, a class) — broad :has(*) on large
subtrees costs style recalculation.
Logical properties
| Physical | Logical |
|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
padding-top / padding-bottom | padding-block-start / padding-block-end |
left / right | inset-inline-start / inset-inline-end |
width / height | inline-size / block-size |
text-align: left | text-align: start |
border-top-left-radius | border-start-start-radius |
Use logical properties by default in any product that may ship RTL languages (see
i18n-localization). Keep physical ones only for things tied to the screen, such as a
shadow’s light direction.
Native nesting
Nest one level for states and modifiers; deeper nesting recreates Sass specificity problems.
.menu {
display: grid;
&:focus-within { outline: 2px solid var(--color-focus); }
& > li { padding: var(--space-2); }
@media (prefers-reduced-motion: no-preference) { transition: opacity 150ms; }
}
A nested selector behaves like :is(parent) child, so its specificity takes the parent’s.
Hard rules
!importantonly in utilities that must win (.visually-hidden,.hidden) and in reduced-motion/forced-colors safety rules.- Never remove focus outlines without a visible replacement.
- Do not use
z-indexvalues without a scale; define--z-dropdown,--z-modal,--z-toasttokens and create stacking contexts deliberately (isolation: isolate). - Avoid fixed heights on text containers; content and zoom will overflow them.
- Test in forced-colors mode (
@media (forced-colors: active)) when using background colors to convey state.
Verification
- Re-run the specificity report; the max specificity and
!importantcount dropped (or stayed within thresholds) and no ID selectors remain in component CSS. - Visual check of key pages at 320 px, 768 px, and wide desktop, plus RTL (
dir="rtl"on<html>) if supported. - Place a component in a narrow sidebar and a wide main column: container queries switch layouts correctly.
- Devtools Styles panel on an element with a reported override bug: the winning rule
wins by layer or source order, not by
!important.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/css_specificity_report.py
#!/usr/bin/env python3
"""Report CSS specificity hot spots and !important usage (stdlib only).
Usage:
python3 css_specificity_report.py FILE.css [FILE2.css ...] [--top 20]
[--max-specificity A,B,C] [--max-important N] [--max-depth N]
Prints totals (selectors, ID selectors, !important, deep descendant chains) and the
highest-specificity selectors with file:line. Exits 1 when a threshold is exceeded,
2 on bad input. Nested rules are scored as written (approximate). Run on compiled CSS
for Sass/Less sources.
"""
import argparse
import re
import sys
SKIP_BLOCK_AT = ("@keyframes", "@-webkit-keyframes", "@font-face", "@page", "@property",
"@counter-style", "@font-feature-values", "@view-transition", "@position-try")
LEGACY_PSEUDO_ELEMENTS = {"before", "after", "first-line", "first-letter"}
def strip_comments(css: str) -> str:
# replace comments with same number of newlines to keep line numbers
return re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), css, flags=re.S)
def split_top(s: str, sep: str = ","):
parts, depth, cur, quote = [], 0, [], None
for ch in s:
if quote:
cur.append(ch)
if ch == quote:
quote = None
continue
if ch in "\"'":
quote = ch
elif ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == sep and depth == 0:
parts.append("".join(cur)); cur = []
else:
cur.append(ch)
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def read_paren(s: str, i: int):
"""s[i] == '('; return (inner, index after closing paren)."""
depth, j = 0, i
while j < len(s):
if s[j] == "(":
depth += 1
elif s[j] == ")":
depth -= 1
if depth == 0:
return s[i + 1:j], j + 1
j += 1
return s[i + 1:], len(s)
def add(a, b):
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def specificity(sel: str):
sel = re.sub(r"\"[^\"]*\"|'[^']*'", "\"\"", sel)
spec = (0, 0, 0)
i, n = 0, len(sel)
while i < n:
ch = sel[i]
if ch == "#":
m = re.match(r"#[\w-]+", sel[i:])
spec = add(spec, (1, 0, 0)); i += len(m.group(0)) if m else 1
elif ch == ".":
m = re.match(r"\.[\w-]+", sel[i:])
spec = add(spec, (0, 1, 0)); i += len(m.group(0)) if m else 1
elif ch == "[":
j = sel.find("]", i)
spec = add(spec, (0, 1, 0)); i = j + 1 if j != -1 else n
elif ch == ":":
if sel.startswith("::", i):
m = re.match(r"::[\w-]+", sel[i:])
spec = add(spec, (0, 0, 1)); i += len(m.group(0)) if m else 2
if i < n and sel[i] == "(":
_, i = read_paren(sel, i)
continue
m = re.match(r":([\w-]+)", sel[i:])
if not m:
i += 1; continue
name = m.group(1).lower(); i += len(m.group(0))
arg = None
if i < n and sel[i] == "(":
arg, i = read_paren(sel, i)
if name == "where":
continue
if name in ("is", "not", "has", "matches", "-webkit-any", "-moz-any") and arg is not None:
best = max((specificity(a) for a in split_top(arg)), default=(0, 0, 0))
spec = add(spec, best)
elif name in ("nth-child", "nth-last-child") and arg is not None and " of " in arg:
best = max((specificity(a) for a in split_top(arg.split(" of ", 1)[1])), default=(0, 0, 0))
spec = add(add(spec, (0, 1, 0)), best)
elif name in LEGACY_PSEUDO_ELEMENTS:
spec = add(spec, (0, 0, 1))
else:
spec = add(spec, (0, 1, 0))
elif ch.isalpha() or ch == "-" or ch == "_":
m = re.match(r"[\w-]+", sel[i:])
spec = add(spec, (0, 0, 1)); i += len(m.group(0))
else:
i += 1 # combinators, *, &, whitespace
return spec
def compound_depth(sel: str) -> int:
flat = re.sub(r"\([^()]*\)", "", sel)
flat = re.sub(r"\s*([>+~])\s*", r" \1 ", flat)
return len([t for t in flat.split() if t not in (">", "+", "~")])
def parse(path: str):
with open(path, encoding="utf-8", errors="replace") as fh:
css = strip_comments(fh.read())
selectors, important, stack = [], 0, []
buf_start, line = 0, 1
i = 0
while i < len(css):
ch = css[i]
if ch in "\"'":
j = css.find(ch, i + 1)
i = j + 1 if j != -1 else len(css)
continue
if ch == "{":
prelude = css[buf_start:i].strip()
skipping = any(s == "skip" for s in stack)
if prelude.startswith("@"):
stack.append("skip" if prelude.lower().startswith(SKIP_BLOCK_AT) else "at")
else:
stack.append("rule")
if not skipping and prelude:
for s in split_top(prelude):
selectors.append((s, specificity(s), compound_depth(s), path, line))
buf_start = i + 1
elif ch == "}":
if re.search(r"!\s*important", css[buf_start:i], re.I):
important += 1 # last declaration without a trailing semicolon
if stack:
stack.pop()
buf_start = i + 1
elif ch == ";":
decl = css[buf_start:i]
if re.search(r"!\s*important", decl, re.I):
important += 1
buf_start = i + 1
elif ch == "\n":
line += 1
i += 1
return selectors, important
def main() -> int:
ap = argparse.ArgumentParser(description="CSS specificity report")
ap.add_argument("files", nargs="+")
ap.add_argument("--top", type=int, default=20)
ap.add_argument("--max-specificity", help="fail if any selector exceeds A,B,C (e.g. 0,3,0)")
ap.add_argument("--max-important", type=int, help="fail if !important count exceeds N")
ap.add_argument("--max-depth", type=int, default=3, help="flag descendant chains deeper than N (default 3)")
args = ap.parse_args()
all_sel, total_imp = [], 0
for f in args.files:
try:
sels, imp = parse(f)
except OSError as e:
print(f"error: {e}", file=sys.stderr)
return 2
all_sel += sels
total_imp += imp
ids = [s for s in all_sel if s[1][0] > 0]
deep = [s for s in all_sel if s[2] > args.max_depth]
print(f"files: {len(args.files)} selectors: {len(all_sel)} with IDs: {len(ids)} "
f"!important: {total_imp} deeper than {args.max_depth}: {len(deep)}")
if all_sel:
hist = {}
for s in all_sel:
key = "id" if s[1][0] else f"0,{min(s[1][1], 4)}{'+' if s[1][1] > 4 else ''}"
hist[key] = hist.get(key, 0) + 1
print("by class-level specificity: " + ", ".join(f"{k}: {v}" for k, v in sorted(hist.items())))
print(f"\nTop {args.top} by specificity:")
for sel, spec, depth, path, line in sorted(all_sel, key=lambda s: s[1], reverse=True)[:args.top]:
print(f" ({spec[0]},{spec[1]},{spec[2]}) depth {depth} {path}:{line} {sel[:110]}")
failed = False
if args.max_specificity:
try:
limit = tuple(int(x) for x in args.max_specificity.split(","))
assert len(limit) == 3
except (ValueError, AssertionError):
print("error: --max-specificity must look like 0,3,0", file=sys.stderr)
return 2
over = [s for s in all_sel if s[1] > limit]
if over:
failed = True
print(f"\nFAIL: {len(over)} selector(s) exceed specificity {args.max_specificity}")
if args.max_important is not None and total_imp > args.max_important:
failed = True
print(f"FAIL: !important count {total_imp} exceeds {args.max_important}")
if not failed:
print("\nOK: within thresholds" if (args.max_specificity or args.max_important is not None) else "")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.