DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Added Custom CSS Injection to Watch Pages

Color pickers cover the basics, but designers want full control. We added a custom CSS field that lets users restyle every element on their video watch pages — with server-side injection and security guardrails.

We shipped custom branding a few days ago: logo, company name, four color pickers, footer text. Within hours, our first power user — a designer — asked for more. He wanted to change the font, round the buttons, add a box shadow to the video player, and hide the transcript section entirely. Color pickers don't cover that.

Rather than adding twenty more toggles, we added one textarea: paste in CSS, and it gets injected into the watch page. Full control over every element.

The injection point

The watch page is a Go HTML template with an inline <style> block. Custom CSS goes at the very end of that block, after all the default styles:

<style nonce="{{.Nonce}}">
    :root {
        --brand-bg: {{.Branding.ColorBackground}};
        --brand-accent: {{.Branding.ColorAccent}};
        /* ... */
    }
    body { background: var(--brand-bg); /* ... */ }
    .container { max-width: 960px; /* ... */ }
    /* ~500 lines of default styles */

    {{if .CustomCSS}}{{.CustomCSS}}{{end}}
</style>
Enter fullscreen mode Exit fullscreen mode

Because custom CSS comes last, it naturally overrides any default rule with equal specificity. Users don't need !important — they just write normal selectors and their styles win.

Security: what can go wrong

Injecting user-provided CSS into a <style> tag opens a few attack vectors:

Style tag breakout. If the CSS contains </style>, the browser closes the style tag and interprets everything after it as HTML. An attacker could inject </style><script>alert('xss')</script> and run arbitrary JavaScript.

External resource loading. @import url("https://evil.com/track.css") lets CSS pull in external stylesheets, which can track viewers by IP address and leak the page URL via the Referer header.

We reject both with simple string checks:

func sanitizeCustomCSS(css string) (string, string) {
    if len(css) > maxCustomCSSLength {
        return "", "custom CSS must be 10KB or smaller"
    }
    lower := strings.ToLower(css)
    if strings.Contains(lower, "</style") {
        return "", "custom CSS must not contain closing style tags"
    }
    if strings.Contains(lower, "@import url(") {
        return "", "custom CSS must not contain @import url()"
    }
    return css, ""
}
Enter fullscreen mode Exit fullscreen mode

The checks are case-insensitive. The 10KB limit is generous for CSS (most custom stylesheets are under 1KB) but prevents abuse.

We considered using a full CSS parser to validate the input, but it adds a dependency for minimal gain. The two string checks cover the actual security risks. Invalid CSS — typos, broken selectors, syntax errors — is harmless. The browser ignores it.

Go's template.CSS type

Go's html/template package auto-escapes values to prevent XSS. If you pass a plain string into a <style> context, it gets escaped into something unusable. Go provides template.CSS as an explicit opt-in for trusted CSS content:

type watchPageData struct {
    // ...
    CustomCSS template.CSS
}

// After sanitization passes:
data.CustomCSS = template.CSS(branding.CustomCSS)
Enter fullscreen mode Exit fullscreen mode

This is intentional — we've validated the CSS, and template.CSS tells Go's template engine "this is safe to render verbatim." It's the same pattern Go uses for template.HTML and template.JS.

User-level, not per-video

Custom CSS applies at the user level only. Unlike the other branding fields (company name, colors, logo, footer), there's no per-video CSS override. The reasoning:

  1. CSS is complex enough that managing it per-video would be confusing
  2. Most users want a consistent look across all their videos
  3. The existing per-video color overrides handle the most common case of varying one video's appearance

If you need one video to look different, use the per-video color overrides. If you need completely different styling, custom CSS on your account affects all your videos uniformly.

What you can customize

The watch page has about 50 CSS selectors covering every visible element. Here are the most useful ones:

Layout and typography:

body { font-family: 'Georgia', serif; }
.container { max-width: 720px; }
h1 { font-size: 1.25rem; letter-spacing: -0.02em; }
.meta { font-style: italic; }
Enter fullscreen mode Exit fullscreen mode

Video player:

video {
    border-radius: 16px;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
Enter fullscreen mode Exit fullscreen mode

Buttons:

.download-btn { border-radius: 20px; }
.comment-submit { border-radius: 20px; }
.speed-btn { border-radius: 12px; }
Enter fullscreen mode Exit fullscreen mode

Hide entire sections:

.comments-section { display: none; }
.transcript-section { display: none; }
.speed-controls { display: none; }
.branding { display: none; }  /* footer */
Enter fullscreen mode Exit fullscreen mode

Override brand colors (same as the color pickers, but in CSS):

:root {
    --brand-bg: #ffffff;
    --brand-text: #1e293b;
    --brand-accent: #2563eb;
    --brand-surface: #f1f5f9;
}
Enter fullscreen mode Exit fullscreen mode

The full selector reference is in CUSTOM-CSS.md and also available inline in the Settings page via a collapsible "Available CSS selectors" section below the textarea.

A complete example: light theme

The default watch page is dark. Here's a light theme in 15 lines:

:root {
    --brand-bg: #ffffff;
    --brand-surface: #f1f5f9;
    --brand-text: #1e293b;
    --brand-accent: #2563eb;
}
.meta { color: #64748b; }
.comment-author { color: #1e293b; }
.comment-body { color: #334155; }
.comment-meta { color: #64748b; }
.transcript-text { color: #334155; }
.speed-btn { border-color: #cbd5e1; color: #64748b; }
.comment-form input,
.comment-form textarea { border-color: #cbd5e1; color: #1e293b; }
.logo { color: #64748b; }
Enter fullscreen mode Exit fullscreen mode

Because most of the page uses CSS variables, changing the four :root values handles 80% of the theming. The remaining lines adjust hardcoded colors on secondary elements.

The data model

One column added to the existing user_branding table:

ALTER TABLE user_branding ADD COLUMN custom_css TEXT;
Enter fullscreen mode Exit fullscreen mode

Nullable, like every other branding field. NULL means no custom CSS. The branding resolution function picks it up in the same query that fetches colors and logo:

SELECT ub.company_name, ub.logo_key, ub.color_background,
       ub.color_surface, ub.color_text, ub.color_accent,
       ub.footer_text, ub.custom_css
FROM user_branding ub
WHERE ub.user_id = $1
Enter fullscreen mode Exit fullscreen mode

No new tables, no new joins, no new API endpoints. Custom CSS is a field on the existing PUT /api/branding endpoint.

The Settings UI

The branding section in Settings already had color pickers, a logo upload, and text fields. We added a monospace <textarea> with:

  • A placeholder showing realistic CSS examples
  • A 10KB character limit matching the backend validation
  • A collapsible "Available CSS selectors" reference listing every class and ID on the watch page, organized by section

The reference is inline rather than linking to external docs. When you're writing CSS to style a page, you want the selector list right next to your editor, not in a separate browser tab.

What we didn't build

CSS preview. There's no live preview of how your CSS will look. The fastest way to check is to save your branding and open one of your shared video links. A preview would require either rendering the watch page in an iframe (cross-origin complexity) or rebuilding the template in React (duplication). Neither is worth the complexity for a feature used infrequently — most users set their CSS once and leave it.

CSS syntax highlighting. The textarea is monospace but has no syntax highlighting. Adding CodeMirror or Monaco would increase the frontend bundle significantly for a feature most users never touch. Plain monospace is good enough for 15 lines of CSS.

Per-video CSS. As discussed above, CSS applies to all videos. The per-video branding overrides (colors, company name, footer) handle the common case of varying individual videos.

Font hosting. Users can change font-family to any system font, but loading custom web fonts would require either @import url() (which we block) or hosting font files on SendRec's S3 storage. We may add font upload support later, but system fonts cover most needs and load instantly.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. Custom CSS is live at app.sendrec.eu — go to Settings, scroll to Watch Page Branding, paste your CSS, and share a video to see the result. The full selector reference is in CUSTOM-CSS.md.

Top comments (0)