All skills
ccheney avatar

/modern-css

@f71635f

Implement or debug CSS layouts, responsive styles, themes, and motion. Use when choosing native CSS features or replacing legacy styling with browser-compatible CSS; not for unrelated frontend logic.

Use this Skill: https://skilld.dev/gh/ccheney/robust-skills/modern-css

This session only. Nothing lands on disk.

referencesSELECTORS.md

≈3.8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Selectors — Targeting Elements Precisely

Sources: CSS Selectors Level 4, MDN CSS Selectors, CSS Wrapped 2025. All selectors below are Baseline unless noted.

Contents


:has() — The Parent Selector

The most impactful CSS selector ever added. :has() selects an element based on what it contains or what follows it. Before :has(), styling a parent based on its children required JavaScript. That era is over. Baseline in all modern browsers. The #1 most-loved CSS feature in developer surveys since its release.

:has() does not select the argument — it selects the element the :has() is attached to, if the argument matches somewhere inside (or after) it.

Card With vs Without Image

/* ❌ JavaScript approach — classList toggling */
document.querySelectorAll('.card').forEach(card => {
  if (card.querySelector('img')) card.classList.add('card--has-image');
});
/* ✅ Pure CSS — no JS, no extra classes */
.card:has(img) { grid-template-rows: 200px 1fr; }
.card:not(:has(img)) { grid-template-rows: 1fr; padding-block-start: 2rem; }

Form Validation Styling

/* ❌ JavaScript approach */
form.addEventListener('input', () => {
  form.classList.toggle('form--error', !!form.querySelector(':invalid'));
});
/* ✅ Pure CSS — reacts instantly, no event listeners */
form:has(:invalid) { border-color: oklch(0.65 0.25 25); }
form:has(:invalid) .submit-btn { opacity: 0.5; pointer-events: none; }
form:not(:has(:invalid)) .submit-btn { background: oklch(0.65 0.2 145); }

Label Reacting to Sibling Input State

Use the adjacent sibling combinator inside :has() to select a label based on its neighboring input.

label:has(+ input:focus) { color: oklch(0.55 0.2 260); font-weight: 700; }
label:has(+ input:placeholder-shown) { opacity: 0.6; }
label:has(+ input:not(:placeholder-shown):invalid) { color: oklch(0.65 0.25 25); }

Quantity Queries

Style containers differently based on how many children they contain.

/* 5+ items — compact layout */
ul:has(li:nth-child(5)) { font-size: 0.875rem; gap: 0.25rem; }

/* 10+ items — multi-column */
ul:has(li:nth-child(10)) { columns: 2; }

/* Exactly 1 item */
ul:has(> li:only-child) { list-style: none; padding: 0; }

/* 4+ cards — switch to grid */
.card-grid:has(.card:nth-child(4)) {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}

Global Page State

body:has(dialog[open]) { overflow: hidden; }

/* Dark mode toggle without JS (checkbox hack + :has) */
html:has(#dark-mode-toggle:checked) { color-scheme: dark; }

.sidebar:has(nav li:nth-child(8)) { overflow-y: auto; max-block-size: 100dvh; }

Feature Detection and Performance

:has() is Baseline, but for defensive coding: @supports selector(:has(*)) { ... }.

Scope :has() to specific elements. .card:has(img) is fast. *:has(*) is not. Avoid overly broad :has() in high-frequency selectors.


:is() vs :where() — Selector List Matching

Both accept a forgiving selector list and match if any selector in the list matches. Same purpose: reducing repetition. The difference is specificity.

Selector Specificity Use When
:is() Takes the highest specificity in its list Normal component styling
:where() Always zero specificity Resets, defaults, layers that should be easy to override
/* :is() — specificity of its most specific argument */
:is(#header, .nav, footer) a {
  /* Specificity: (1,0,1) — takes #header's ID specificity */
  color: oklch(0.55 0.2 260);
}

/* :where() — zero specificity regardless of arguments */
:where(#header, .nav, footer) a {
  /* Specificity: (0,0,1) — only the `a` counts */
  color: oklch(0.55 0.2 260);
}

Use :is() for Normal Styling

/* ❌ Repetitive */
header a:hover, nav a:hover, footer a:hover { text-decoration: underline; }

/* ✅ Grouped with :is() */
:is(header, nav, footer) a:hover { text-decoration: underline; }

.btn:is(:hover, :focus-visible) { background: oklch(0.5 0.2 260); }
:is(h1, h2, h3, h4):is(:first-child) { margin-block-start: 0; }

Use :where() for Overridable Defaults

/* Reset — any author style should win without specificity fights */
:where(ul, ol) { list-style: none; padding: 0; margin: 0; }
:where(a) { color: inherit; text-decoration: none; }

/* Base layer defaults */
:where(.prose) :where(h1, h2, h3) { font-weight: 700; line-height: 1.2; }

/* Override with zero effort — any class selector wins over :where() */
.custom-heading { font-weight: 400; }

Pairs well with @layer:

@layer reset {
  :where(*) { margin: 0; padding: 0; box-sizing: border-box; }
}

Forgiving Selector Lists

Both use forgiving parsing. If one selector in the list is invalid, the rest still work.

/* Regular list: if :unknown-pseudo is invalid, ENTIRE rule fails */
.a, :unknown-pseudo, .b { color: red; }

/* :is() / :where(): invalid selectors skipped, .a and .b still match */
:is(.a, :unknown-pseudo, .b) { color: red; }

:not() Enhanced — Complex Negation

:not() now accepts complex selector lists (Selectors Level 4). Previously limited to a single simple selector.

/* ❌ Old: chained single :not() calls */
li:not(:first-child):not(:last-child) { border-block-start: 1px solid oklch(0.8 0 0); }

/* ✅ New: selector list in one :not() */
li:not(:first-child, :last-child) { border-block-start: 1px solid oklch(0.8 0 0); }

Practical Patterns

form :not([type="submit"], [type="hidden"]) { margin-block-end: 1rem; }
:not(nav, footer) > a { text-decoration: underline; }
:is(button, input, select, textarea):not(:disabled) { cursor: pointer; }
.card:not(:has(img), .featured) { background: oklch(0.97 0 0); }

Specificity of :not() is taken from its most specific argument, same as :is().


:focus-visible — Accessible Focus Styling

Use :focus-visible instead of :focus for visible focus indicators. The browser shows :focus-visible styles only on keyboard navigation (Tab, arrow keys) or assistive technology — not on mouse clicks. This is the accessibility-correct approach.

:focus applies on every focus event including clicks, causing unwanted focus rings that lead developers to remove outlines entirely — breaking keyboard accessibility.

/* ❌ Anti-pattern: removing all outlines */
*:focus { outline: none; }

/* ❌ Shows focus ring on mouse click */
.btn:focus { outline: 3px solid oklch(0.55 0.2 260); }

/* ✅ Shows focus ring only on keyboard navigation */
.btn:focus-visible {
  outline: 3px solid oklch(0.55 0.2 260);
  outline-offset: 2px;
}

Standard Pattern

:focus { outline: none; }
:focus-visible { outline: 2px solid oklch(0.55 0.2 260); outline-offset: 2px; }

Combining with Other Selectors

.btn:hover { background: oklch(0.45 0.2 260); }
.btn:focus-visible { outline: 3px solid oklch(0.7 0.2 260); outline-offset: 2px; }

/* Highlight parent on keyboard focus */
.input-group:has(:focus-visible) {
  box-shadow: 0 0 0 3px oklch(0.7 0.15 260 / 0.3);
}

/* Only show ring when internal focus is from keyboard */
.dropdown:focus-within:has(:focus-visible) {
  outline: 2px solid oklch(0.55 0.2 260);
}

Scroll-Driven State: :target and Scroll-State Queries

:target — Fragment-Based Styling

Matches the element whose id matches the URL fragment (#hash). Use for scroll-to-section highlighting and in-page navigation.

section:target {
  background: oklch(0.95 0.05 80);
  border-inline-start: 4px solid oklch(0.55 0.2 260);
}

html { scroll-behavior: smooth; }
section:target { animation: flash 1s ease-out; }

@keyframes flash {
  from { background: oklch(0.9 0.1 80); }
  to { background: transparent; }
}

Scroll-State Container Queries

For true scroll-spy behavior (styling based on scroll position), use scroll-state container queries. These track stuck (sticky) and snapped (scroll-snap) states.

As with all container queries, rules inside @container can only style descendants of the container — never the container itself. Put the visual styles on an inner wrapper element. See SCROLL.md for the full pattern.

/* Stuck state — detect when sticky element is stuck.
   Styles apply to .sticky-header-inner, a child of the container. */
.sticky-header {
  position: sticky;
  top: 0;
  container-type: scroll-state;
}

@container scroll-state(stuck: top) {
  .sticky-header-inner {
    box-shadow: 0 2px 8px oklch(0 0 0 / 0.15);
    border-block-end: 1px solid oklch(0.85 0 0);
  }
}

/* Snapped state — detect when element is snapped */
.carousel-item {
  scroll-snap-align: center;
  container-type: scroll-state;
}

@container scroll-state(snapped: x) {
  .carousel-item-content { scale: 1.05; opacity: 1; }
}

Support: Chromium-only (Chrome/Edge 133+) as of mid-2026. Feature-detect: @supports (container-type: scroll-state) { ... }


Modern Pseudo-Elements Reference

Baseline (Use Freely)

Pseudo-Element What It Targets Use When
::marker Bullet/number of list items Custom list styling (color, size, content)
::backdrop Overlay behind <dialog> or fullscreen Dimming/blurring behind modals
::placeholder Placeholder text in inputs Styling hint text (color, font, opacity)
::selection User-highlighted text Brand-colored text selection
::details-content Content area of <details> Animating accordions (Baseline since Sep 2025: Chrome 131+, Firefox 143+, Safari 18.4+)
/* Animate <details> open/close — no JS accordion needed */
details::details-content {
  block-size: 0;
  overflow: clip;
  interpolate-size: allow-keywords; /* Chromium-only: enables 0 → auto interpolation */
  transition: block-size 0.3s ease, content-visibility 0.3s ease allow-discrete;
}
details[open]::details-content {
  block-size: auto;
}
li::marker { color: oklch(0.55 0.2 260); font-size: 1.2em; }
li::marker { content: '-- '; }

dialog::backdrop { background: oklch(0.15 0 0 / 0.6); backdrop-filter: blur(4px); }
input::placeholder { color: oklch(0.6 0 0); font-style: italic; }
::selection { background: oklch(0.55 0.2 260 / 0.3); color: oklch(0.2 0 0); }

View Transition Pseudo-Elements (Baseline Newly Available Oct 2025)

Exist only during a same-document view transition (Baseline since Firefox 144, October 2025). Customize animation between old and new states.

Pseudo-Element What It Targets
::view-transition Root overlay for the entire transition
::view-transition-group(name) Container animating between old/new
::view-transition-old(name) Snapshot of the outgoing state
::view-transition-new(name) Snapshot of the incoming state
::view-transition-image-pair(name) Container holding old and new snapshots
.hero-image { view-transition-name: hero; }
::view-transition-old(hero) { animation: fade-out 0.3s ease-out; }
::view-transition-new(hero) { animation: fade-in 0.3s ease-in; }
::view-transition-old(root) { animation-duration: 0.2s; }

Chromium-Only / Emerging (Feature-Detect Required)

As of mid-2026 these ship only in Chromium (Safari 27 has announced customizable <select> support). Treat as progressive enhancement.

Pseudo-Element What It Targets Replaces
::picker(select) Dropdown of customizable <select> Custom dropdown widgets
::picker-icon Dropdown arrow on <select> CSS hacks for arrow styling
::checkmark Checkmark in <option> Custom checkmark styling
::selectedcontent Displayed selected value in <select> JS-based display sync
::scroll-button(*) Prev/next buttons for overflow containers JS carousel controls
::scroll-marker Individual scroll indicator (dot) JS scroll indicator dots
::scroll-marker-group Container for scroll markers JS carousel dot container
/* Native carousel controls */
.carousel { overflow-x: auto; scroll-snap-type: x mandatory; }
.carousel::scroll-button(inline-start) { content: '<'; }
.carousel::scroll-button(inline-end) { content: '>'; }

.carousel::scroll-marker-group {
  display: flex; gap: 0.5rem; justify-content: center; padding-block: 0.5rem;
}
.carousel > *::scroll-marker {
  content: ''; inline-size: 12px; block-size: 12px;
  border-radius: 50%; background: oklch(0.75 0 0);
}
.carousel > *::scroll-marker:target-current { background: oklch(0.45 0.2 260); }

Customizable <select> Pseudo-Elements

Opt in with appearance: base-select, then style sub-parts:

select { appearance: base-select; }
select::picker(select) {
  background: oklch(0.98 0 0); border: 1px solid oklch(0.8 0 0);
  border-radius: 0.5rem; padding: 0.25rem;
}
select::picker-icon { transition: rotate 0.2s; }
select:open::picker-icon { rotate: 180deg; }
option::checkmark { color: oklch(0.55 0.2 145); }
selectedcontent { font-weight: 600; }

Combining Modern Selectors

The real power emerges from combining selectors. Each example replaces JavaScript.

/* Auto-hide sections with no content beyond a heading */
section:not(:has(*:not(h2))) { display: none; }

/* Dim inactive nav items when one is active */
nav:has(.active) :is(a, button):not(.active) { opacity: 0.6; }

/* Highlight fieldset on keyboard focus */
fieldset:has(:is(input, select, textarea):focus-visible) {
  background: oklch(0.97 0.02 260);
}

/* Grid columns based on item count */
.grid:has(:nth-child(7)) { grid-template-columns: repeat(3, 1fr); }
.grid:not(:has(:nth-child(4))) {
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}

/* Skip-link visible only on keyboard focus */
.skip-link:focus-visible {
  position: fixed; inset-block-start: 1rem; inset-inline-start: 1rem; z-index: 9999;
}

For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.

Source: SKILL.md on GitHub

1 warning15d4 checks · Risk SAFE
  • Gen Agent Trust Hub15d

    The skill is a secure and comprehensive educational resource for modern CSS development. It provides guidance on using native browser features for layout, animation, and performance without the need for JavaScript or external libraries. No malicious behaviors, exfiltration attempts, or prompt injections were found.

  • Socket15d

    No alerts

  • Snyk15d

    Risk: LOW · No issues

  • Runlayer7mo

    11/11 files flagged

Signed by skilld at f71635f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago

README badge

README badge for ccheney/robust-skills/modern-css