CSS Hacks That Still Work in 2026
Each of these is a handful of lines, needs no JavaScript, and does something that used to need a library. Vendor-prefix notes are current-WebKit/Gecko/Chromium; treat anything marked new as progressively enhanced — inert where unsupported, never broken.
1. Dark mode with no class and no JS
:root { color-scheme: light dark; --surface: light-dark(#ffffff, #0d1117); --text: light-dark(#1a1f26, #e6edf3); }
light-dark() resolves against color-scheme, so the OS toggle is the only switch. color-scheme additionally fixes form controls, scrollbars, and input pickers — a dark stylesheet without it leaves a white native date picker on a black page. Wrap the whole thing in @media (prefers-color-scheme: dark) if you need to support engines without light-dark(); new, so keep your two literal palettes as the fallback.
2. A shadow that appears only when there is content under the header
.scroller { overflow: auto; max-height: 60vh;
background: linear-gradient(var(--surface) 30%, transparent) top/100% 24px no-repeat,
linear-gradient(transparent, var(--surface) 70%) bottom/100% 24px no-repeat,
radial-gradient(farthest-side, rgb(0 0 0/.35), transparent) top/100% 12px no-repeat;
background-attachment: local, local, scroll; }
The local attachments scroll with the content and the scroll one stays put, so the shadow only renders while something is actually hidden beneath the edge. This was a 40-line scroll listener for a decade.
3. Validate a form with no JavaScript
form:has(input:invalid) button[type=submit] { opacity: .4; pointer-events: none; }
label:has(input:invalid) { color: firebrick; }
.field:has(input:not(:placeholder-shown))::after { content: "✓"; color: seagreen; }
:has() is a parent selector, which is why it feels illegal. It is supported across current Safari, Chrome, and Firefox. Keep the native constraint check as the real gate — the styling is a hint, not security.
4. Container queries: components that know their own width
.card-wrap { container-type: inline-size; }
@container (min-width: 30rem) { .card { grid-template-columns: 120px 1fr; } }
Layout now follows the space the card was given, not the viewport. This is what makes a component reusable in a sidebar and a hero without two class sets. One container per wrapper; container-type: inline-size on an element you also want to size from its children needs container-type: size and a definite block size.
5. Underlines that do not cross descenders
a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; text-decoration-skip-ink: from-font; }
skip-ink is what stops a line cutting through a g. Three declarations, and the single most visible polish on a text-heavy page.
6. Fluid type with no media queries
h1 { font-size: clamp(1.6rem, 1.1rem + 2.4vw, 3rem); }
.card { width: min(100%, 34rem); }
clamp()/min()/max() replace four breakpoints. Keep the middle term in rem + vw so browser zoom still scales it; a pure vw middle term stops growing when a user zooms, which is an accessibility bug.
7. One declaration that stops the mobile 300 ms double-tap zoom surprise
input[type=number], input[type=text], textarea { font-size: max(1rem, 1em); }
iOS zooms the viewport when a focused input is smaller than 16 px. Setting font-size: 1rem on form fields removes the jump, and the max() keeps it from shrinking below the user's preference.
8. Balance a headline without a JS line-splitter
h1, h2, .lede { text-wrap: balance; }
p { text-wrap: pretty; }
Orphan words on a hero line and rivers in a paragraph, both gone, both zero cost where unsupported.
9. Sticky footer with no negative margins
body { min-height: 100dvh; display: grid; grid-template-rows: auto 1fr auto; }
Three rows, and 1fr absorbs the slack. dvh avoids the mobile URL-bar overflow that vh causes, which historically meant a page exactly 100vh tall scrolled a few pixels when it should not.
10. Scroll-linked progress and reveal, no scroll listener
@keyframes grow { from { width: 0 } to { width: 100% } }
.progress { animation: grow linear both; animation-timeline: scroll(root block); }
@supports (animation-timeline: view()) {
.reveal { animation: fade-in linear both; animation-timeline: view(); animation-range: entry 0% cover 30%; }
}
New: scroll-driven animations run on the compositor with no main-thread work. Always wrap in @supports, and honour prefers-reduced-motion by shortening animation-duration — these animate on scroll, which is exactly the trigger that makes people ill.
11. Popover and dialog with no polyfill
dialog::backdrop { background: rgb(0 0 0/.55); backdrop-filter: blur(2px); }
[popover] { border: 0; padding: 1rem; background: Canvas; color: CanvasText; }
<dialog> and popover give you focus trapping, Escape-to-close, and inert backgrounds for free — the behaviour that jQuery modal plugins existed to provide. Canvas/CanvasText system colors follow dark mode automatically.
One thing not to try
* { transition: all .2s }. It animates layout properties you did not mean to animate, runs on every inherited change, and turns a hover into a jank frame. Name the two properties you are transitioning; selectors and specificity covers why * selectors are cheap to write and expensive to reason about, and patterns and gotchas covers the stacking contexts transitions create.