CodeForge
← All posts
Engineering·September 2, 2026·6 min read

Four themes, one widget: keeping CSS from fighting itself

Notes from building a streaming widget system with four structurally different visual presets — and the specificity bug that almost broke all of them at once.

On a recent project — a set of live-overlay widgets for streamers — the brief wasn't "support four color themes." It was four visual identities that needed to feel like different products: pastel glass for VTubers, deep violet glass for ambient streams, cyan cyberpunk scanlines for competitive gaming, a chrome-free minimal style for podcasts and coding streams. Different shapes, different type, different effects, not just different hex codes.

That distinction matters for how you architect the CSS. A color-swap theme system can get away with a handful of custom properties. A structural theme system — where border-radius goes to zero, fonts switch to monospace, and box-shadows turn into neon glows — needs each preset to own its own set of rules without four parallel stylesheets fighting for the same selectors.

The approach: data attributes, not classes

We settled on scoping every preset under a single data attribute on the widget root: [data-preset="neon-gaming"] .widget-row, and so on. No class toggling, no per-element inline style injection from JavaScript. Switching a theme is one attribute change on one element, and every descendant selector cascades from there. It keeps the four themes as four clearly separated blocks in the stylesheet, which matters when you're maintaining this across three independently deployed widget repos and need the same mental model in all of them.

Where it actually broke

The bug showed up once the streamer-facing dashboard was wired in. The dashboard lets you customize individual colors on top of a preset — accent color, background tint — and it applies those as inline CSS custom properties on the widget's root element for a live preview. Inline styles and custom properties set that way have very high effective priority, and they were quietly overriding the preset's own shape and background rules, not just its colors. Switching to "Neon Gaming" would apply the right glow color, but the sharp scanline corners and monospace type never showed up if the dashboard had previously touched that widget.

The fix had two parts. First, a clearCSSVars() step that runs before applying a new preset, so stale inline custom properties from a previous session don't leak into the next one. Second, targeted !important overrides on the specific shape and background properties inside each [data-preset] block — not a blanket !important sweep, just the properties that inline styles were realistically going to contest. Broad !important usage is usually a sign you've lost track of your cascade; here it was a scoped, deliberate exception to a known, specific collision.

The part that doesn't show up in a demo

The unglamorous work was keeping this consistent across three separate codebases — the alert widget, the recent-donations widget, and the top-donors widget — each its own React + Vite app with its own deploy pipeline. Same four presets, same selector strategy, same override rules, implemented three times by hand rather than shared through a package, because the widgets needed to stay independently deployable to different Cloudflare Pages projects. The trade-off was more copy-paste discipline in exchange for zero coupling between widgets that stream operators embed and reload independently in OBS.

Ready to build something?

We take on a limited number of projects per quarter. Let's talk before the spots fill up.