CSSTidy CSS Formatter

CSS Hacks That Still Work in 2026

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-12 · All guides

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.

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.

Developer Sponsor / Partner
Copied to clipboard!