I hand-scoped 2,768 CSS demos. Here's the convention that made it survivable.
You paste a snippet from a tutorial. Half your app's buttons change color. The snippet had .btn in it, and so does your codebase. Nothing logged the collision - CSS resolves duplicates silently, by source order, and whichever selector loaded last just won. Every dev who's copy-pasted CSS has hit some version of this. It's the most common way styles regress in a codebase and the least often called out. I ship 2,768 CSS demos across 138 collections on codefronts.com, all hand-written. Every demo has to survive two environments: - My own gallery page - where up to 50 demos render side-by-side under a single stylesheet. If two demos both style .card , one wins and the other breaks in production. - Your project - where the snippet lands next to whatever reset, utility framework, and component library you already have wired up. If my .card beats your.card , I've silently regressed your app. This post is the exact system I settled on. It isn't clever - that's the point. It's mechanical, applied by hand at authoring time, and legible in the output you copy. The class of bug scoping prevents Two demos ship in the same collection. Both need a card element. The natural class name is .card . Written in isolation, both become: .card { background: white; border-radius: 12px; padding: 1.5rem; } Each works fine alone - every demo gets its own /try/ playground iframe during authoring, so there's no cross-demo interference while I'm building it. Then the collection ships. The gallery page merges every demo's CSS into one stylesheet and renders all 20 on a single URL. Whichever .card rule landed last wins. The other 19 look wrong. DevTools flags nothing, because there's nothing to flag - CSS resolves duplicate selectors the same way it resolves any cascade. Now scale that to @keyframes names, custom properties, and element IDs, and multiply by 2,768. The number of ways two demos can silently interfere grows faster than the demo count. A .btn collision is obvious. A shared @keyframes float where one demo animates opacity and another animates translateY is invisible until you notice the wrong thing is moving. Why the standard solutions don't fit Before writing my own convention I worked through every framework-native approach. The question isn't whether they solve style isolation - most solve it cleanly. The question is what each one does to the code you copy. A copy-paste demo site has an unusual constraint: the output has to be legible to someone who's never seen the build system. You land on the page, open the code panel, hit copy, paste into a project I know nothing about. If the snippet carries wrapper elements you don't understand, class names you can't decode, or attributes you don't recognize, the product has already failed you. That constraint kills all four options. CSS Modules Every class gets hashed at build time: .card becomes .card_a3f4b_1 . Guaranteed uniqueness, zero collision risk on my side. Why it fails: you end up with and .card_a3f4b_1 { ... } . The name means nothing to you. You can't rename it, can't grep for it, can't reason about extending it. The entire proposition of a copy-paste library is that you lift the code, rename what you want, and drop it into your own components. Hashed names break that at step one. Shadow DOM Total encapsulation via a shadow root per demo. Nothing leaks in, nothing leaks out, and the browser enforces it. Why it fails: the output ships as wrappers most readers won't recognize. Shadow DOM also breaks event delegation, complicates form submission across the boundary, and confuses anything expecting document.querySelector to reach inside. A meaningful share of my demos use light JavaScript for state - add-to-cart progress, loading buttons, form validation - and every one would need rewriting against the shadow API. After all that work, you'd still get a wrapper you didn't ask for. Astro Astro powers the site and ships first-class scoped styles. Add and every selector gets a hash attribute at build time. Why it fails: the output is .card[data-astro-cid-abc123] . You copy the CSS, but it only matches under Astro's generated attribute. Paste it into Next.js or plain HTML and it matches nothing. I'd have to tell you to strip the attribute selectors first, which defeats the point. I do use for site chrome - nav, footer, marketing pages. Never inside demos. An iframe per demo Zero bleed, isolation guaranteed by the browser. Why it fails at gallery scale: collection pages render 20 to 50 demos on one URL. An iframe each means 50 requests, 50 style parses, 50 layout passes to paint a single page. Time-to-interactive on a mid-range phone crosses five seconds easily, and that lands on every gallery URL on the site. Iframes stay for the single-demo /try/ playground, where isolation buys more than it costs. Not for the gallery. The pattern all four share Each one either mangles the copied output, wraps it in artifacts you didn't ask for, or trades isolation for a page-performance problem. None of them survive the test: the copied code must be plain, self-explanatory CSS you can own and rename. What I actually do Every class, keyframe, custom property, and element ID in every demo gets a two-part namespace, applied by hand at authoring time. - Collection prefix - 2-4 letters fixed when the collection is created. cb forcss-buttons ,md forcss-modals ,rib forcss-ribbons ,tgl forcss-toggles-switches ,bc forcss-breadcrumbs . - Position number - two digits marking the demo's slot. Position 5 in css-buttons iscb-05 . Position 12 incss-modals ismd-12 . Every demo therefore has a unique root: .cb-05 , .md-12 , .rib-08 . Inside the demo, children extend that root BEM-style: .cb-05__btn , .cb-05__stage , .md-12__backdrop . No naked .btn . No naked .card . Every selector begins at the namespace root or descends from it. Keyframes take the same prefix - @keyframes cb-05-spin , @keyframes md-12-fadein - so two demos can both animate rotation and never collide. So do custom properties (--cb-05-glow , --md-12-backdrop-alpha ) and element IDs used for aria-describedby or popover targeting (cb-05-tooltip-target ). Here's a real production file: css-buttons demo 5, the gradient CTA button, exactly as it appears in the code panel. @property --cb-05-a { syntax: ' '; inherits: false; initial-value: 135deg; } .cb-05 { --bg: #08070d; --c1: #6d28d9; --c2: #2dd4bf; --c3: #f472b6; --ink: #ffffff; --body: #141024; --bloom: .55; --radius: 1rem; --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; width: 100%; display: block; background: radial-gradient(60rem 40rem at 50% 120%, color-mix(in oklab, var(--c1) 22%, transparent), transparent 70%), var(--bg); font-family: var(--sans); } @supports (color: oklch(50% 0 0)) { .cb-05 { --c1: oklch(52% .24 293); --c2: oklch(80% .14 182); --c3: oklch(74% .17 349); } } .cb-05, .cb-05 *, .cb-05 *::before, .cb-05 *::after { box-sizing: border-box; } .cb-05__stage { min-height: 100svh; display: grid; place-items: center; padding: clamp(1.5rem, 6vw, 4rem); } .cb-05__btn { position: relative; isolation: isolate; cursor: pointer; display: grid; grid-auto-flow: column; align-items: center; gap: .55rem; min-height: 3.5rem; padding: 0 1.9rem; border: 0; border-radius: var(--radius); font: inherit; font-size: 1rem; font-weight: 600; color: var(--ink); background: var(--body); transition: translate .3s cubic-bezier(.16, 1, .3, 1), scale .18s ease; } .cb-05__btn::before, .cb-05__btn::after { content: ''; position: absolute; inset: -2px; z-index: -1; border-radius: inherit; background: conic-gradient(from var(--cb-05-a), var(--c1), var(--c2), var(--c3), var(--c1)); animation: cb-05-spin 4.5s linear infinite; } .cb-05__btn::after { inset: 2px; filter: blur(20px); opacity: var(--bloom); } @keyframes cb-05-spin { to { --cb-05-a: 495deg; } } .cb-05__btn:hover { translate: 0 -2px; } .cb-05__btn:focus-visible { outline: 2px solid var(--c2); outline-offset: 5px; } @media (prefers-reduced-motion: reduce) { .cb-05__btn::before, .cb-05__btn::after { animation: none; } .cb-05__btn { transition: none; } } Live version: codefronts.com/components/css-buttons/css-gradient-cta-button/ Five scoping surfaces in one file, all under cb-05 : - .cb-05 - the root class, the demo's boundary - .cb-05__stage ,.cb-05__btn - children, prefixed by the root - --cb-05-a - a custom property registered via@property , prefixed so it can't collide with another demo's angle animation - @keyframes cb-05-spin - the animation name inside the namespace - .cb-05, .cb-05 * { box-sizing: border-box } - the reset the demo depends on, scoped to the demo's own subtree Copy that block into any project and nothing outside .cb-05 is touched. Find-and-replace cb-05 with my-button and it still works. Debugging is trivial because every selector explains itself. Where it breaks - the honest list This isn't foolproof. Six failure modes I've hit and either fixed, worked around, or accepted: 1. Discipline is fallible. The system depends on me remembering to prefix, and nothing enforces it at build time. Write .btn inside a demo file and it ships, and you won't find out until something else in the collection looks wrong. The cheap mitigation is a grep for unprefixed selectors before a demo goes live - a build-time lint rule would be better, and it's on my list. 2. !important on the host page beats scope specificity. .cb-05__btn is specificity 0,0,2,0. If your CSS has button { background: red !important } , I lose. That's a specificity problem, not a naming one, and no scoping convention fixes it. 3. Global resets on the host page. Tailwind Preflight, Bootstrap reboot, Normalize - all set button { background: transparent; border: none } at the base layer. Without @layer ordering on your side, the reset can beat the demo's scoped background. Documented per-demo in the gotchas field. 4. The demo assumes :root custom properties you may not have. Some demos reference tokens expecting them from :root . If your project doesn't define them,
Comments
No comments yet. Start the discussion.