Skip to content

Custom CSS

Every element on an overlay can carry your own CSS, and so can the overlay as a whole. Use it when the inspector's settings (colors, fonts, text effects, frames) don't go far enough: glass panels behind chat rows, a neon first place on a leaderboard, an animation on an alert's headline. This page explains where CSS applies and how it's scoped, lists the named parts of every built-in element, and ends with a worked example.

Where CSS goes

Where Set it in Styles
Element CSS The overlay editor's inspector, CSS section, with an element (or group) selected That element only
Overlay CSS The same section with nothing selected, where it's called Overlay CSS Everything on the overlay
Widget CSS The CSS tab of the widget editor The inside of one custom widget, on every overlay that uses it

The CSS section of the overlay editor's inspector for a chat box: the Format button above glass-row CSS in the code editor, the Parts with Show on canvas, and the Starters

The CSS section lists the element's Parts: click one to insert its selector, and switch on Show on canvas to outline the parts in the editor (hover one to see its name). Starters insert ready-made snippets you can adjust: Glass rows, Neon first row, Slide-in rows, Striped bar, Gradient text, Soft shadow and Gentle float, depending on the element; Shadow on everything and Fade in on load for the overlay.

CSS is saved as you type (one undo step per pause), and reaches OBS immediately, with no refresh. CSS that breaks the rules below is refused with a message, so a typo can't break the overlay.

The CSS box

The box is the same code editor as the widget editor's tabs: coloured CSS, line numbers, folding, find and replace (Ctrl+F), several cursors and comment toggling (Ctrl+/). It grows with your CSS up to 16 lines, then scrolls.

It checks the CSS as you type, about a third of a second after you stop:

  • Syntax errors are underlined in red: a missing ; or } reads "CSS syntax error near …" or "The CSS ends early: a "}" or ";" may be missing". Bare declarations (opacity: 0.9; with no selector) are fine here; they style the element itself.
  • Unknown properties are underlined in amber, with a suggestion when one is close: colr: gold reads "Unknown property "colr". Did you mean color?". Custom properties (--sl-gap) and vendor-prefixed ones (-webkit-text-stroke) are never flagged. The list of known properties comes from the web view CroStream runs in, so a very new property may be flagged although OBS supports it; the CSS still applies.

Hover an underline to read its message, or hover the dot next to the line number. The counter under the box (red for errors, amber for warnings) opens the list of all problems; so does Ctrl+Shift+M (Cmd+Shift+M on macOS). These checks are advice: CSS with a warning is saved and applied. The rules in What's allowed are different: CroStream refuses CSS that breaks them, and shows why in red under the box.

The CSS box with colr: gold added on line 7: the property is underlined in amber, the hover reads "Unknown property "colr". Did you mean color?", and the counter under the box shows one warning

Completion: typing a property suggests property names, and after the colon their keywords. Inside var( (or after typing --) it suggests the custom-widget variables --sl-w, --sl-h, the theme colors (--sl-primary …) and fonts (--sl-font-heading, --sl-font-body). Those only have values inside a custom widget's frame, which element CSS on a custom widget also reaches (see On a custom widget); the variables built-in elements read, such as --sl-gap, are in CSS variables below and aren't suggested. Hovering a --sl-… variable shows what it holds.

Format: click Format above the box (greyed out while it's empty), or press Shift+Alt+F (Shift+Option+F on macOS) in it, to tidy the CSS with Prettier: one declaration per line, two-space indentation. Ctrl+Z (Cmd+Z) undoes it. CSS with a syntax error isn't formatted; the reason shows under the box.

The box takes up to 64 KB and stops accepting text at that size.

How element CSS is scoped

You write ordinary CSS; CroStream rewrites it so it can only reach its own element:

  • Every selector is prefixed with the element, so [data-part="row"] { … } matches rows of this element only, never another chat box.
  • The element itself is styled by bare declarations, or by a rule that starts with :scope, &, html, body or :root. These three are the same:

    opacity: 0.9;
    
    :scope { opacity: 0.9; }
    
    & { opacity: 0.9; }
    
  • It wins over the element's own styles without !important: element CSS outranks the overlay CSS, and both outrank the built-in styles.

  • Except inline styles. A style an element sets from its own settings (a color, a gap, a radius you chose in the inspector) is inline, and inline styles win. Override those with the element's CSS variables, or with !important.
  • Nesting works ([data-part="row"] { &:first-child { … } }).
  • @keyframes names are private to the element: two elements can both define @keyframes pulse without clashing. Use the name as written; the rewrite updates animation and animation-name to match.
  • At-rules: @media, @supports, @container, @layer, @starting-style, @font-face and @keyframes are kept. @import, @charset, @namespace, @property, @scope, @page and unknown at-rules are dropped.

Overlay CSS works the same way, scoped to the overlay's canvas: bare declarations and :scope style the canvas, and [data-node-id] matches every element on it:

/* A soft shadow under everything on this overlay. */
[data-node-id] {
  filter: drop-shadow(0 4px 10px rgb(0 0 0 / 0.5));
}

On a custom widget

A custom widget draws inside its own sandboxed frame, so element CSS applies to it twice:

  • Outside the frame, scoped to the element's box like any element: this moves, fades, rotates or filters the whole widget.
  • Inside the frame, as a plain stylesheet after the widget's own CSS: this can restyle the widget's classes for this one element (.title { color: gold; }).

The hint under the CSS box of a custom widget says the same: "This CSS also applies inside the custom widget's frame, after the widget's own CSS, so it can restyle the widget's HTML too."

A rule such as :scope { filter: … } therefore applies once outside and once to the frame's page; keep effects like shadows to one place, or scope the inside rules to the widget's own classes. To change a widget everywhere, edit its CSS in the widget editor instead.

What's allowed

CSS must not load code or reach the network. These are refused when you type them (the box shows the error) or dropped:

  • @import
  • url() to anything but a media library path (/media/…) or a data: URI
  • image-set(), src(), expression(), behavior: and -moz-binding
  • backslash escapes outside strings (they can spell any of the above)
  • </style and <!--, which could end the stylesheet

A stylesheet can be up to 64 KB. Library images work as backgrounds, by their file URL: /media/file/<item id>. The media library doesn't show item IDs; look one up with the HTTP API (LibraryList with a search), or place the picture with an Image element instead.

:scope {
  background: url("/media/file/media-01m4nr00sd9t68d5npjz0bbtvd") center / cover;
}

Widget CSS (the widget editor's CSS tab) has none of these checks, because the widget's frame blocks the network anyway. See the sandbox.

Parts of each element

Each built-in element marks its pieces with data-part. Select them with [data-part="name"].

Element Parts
Text text
Rectangle rect
Frame frame
Line line (an SVG)
Image media
Video media (the poster in thumbnails)
Alert box (the frame), media (picture or video), words (headline and message together), headline, message
Latest plate (the frame behind the text), text
Counter plate, text
Goal track (the bar's background), bar (the filled bar), title, progress (e.g. 120 / 500)
Recent events frame, list, row (one event), icon, text
Chat frame, list, row (one line), name, message
Timer plate, text, time (the box around the digits)
Clock plate, text
Ticker frame, track (the scrolling strip), item, separator
Stream info plate, text
Leaderboard frame, title, list, row (one supporter), medal (the gold, silver or bronze dot), rank, user, value
Icon plate, icon
Socials frame, list, item (one handle), icon, handle
Credits frame, roll (the scrolling roll), title, section, heading, name
Wheel frame, wheel, slice (an SVG path), label, pointer (an SVG path), hub-disc (an SVG circle), hub (the center text), result
Particles canvas
Hype train frame, title, level, timer (time left), track, bar, progress
Poll frame, title (the question), status, list, row (one choice), label, bar, votes, percent
Chat emotes emote (one flying emote)
Slideshow frame, slide (the current picture or video)
Audio (none)

Rows carry extra attributes you can select on:

  • Leaderboard rows: [data-part="row"][data-rank="1"] (the rank, from 1).
  • Recent events rows: [data-part="row"][data-type="twitch.cheer"], by event type (twitch.follow, twitch.sub, twitch.gift, twitch.raid, twitch.redemption, …).
/* Gold first place, and a pink edge on cheers in the event list. */
[data-part="row"][data-rank="1"] [data-part="user"] {
  color: #ffd84d;
  text-shadow: 0 0 10px rgb(255 216 77 / 0.6);
}
[data-part="row"][data-type="twitch.cheer"] {
  border-left: 4px solid #ff5cf0;
}

Parts drawn by the text renderer

Text parts such as headline and message are drawn by the same renderer as <sl-text>: plain color, font, letter-spacing and background-clip: text gradients work on them, while the inspector's Text effects (outlines, glow, gradients) are usually the easier way to get those looks.

CSS variables

Some elements read these variables, which override the matching inspector setting even though it's applied inline. Set them on the element:

Variable Elements Overrides
--sl-gap Alert, Recent events, Socials, Chat Space between pieces, rows or handles
--sl-gap-text Alert Space between headline and message
--sl-color Recent events, Goal Text color
--sl-fill Rectangle Fill
--sl-border-color Rectangle Border color
--sl-radius Rectangle, Image, Video Corner radius
:scope {
  --sl-gap: 30px;
  --sl-gap-text: 12px;
}

Inside a custom widget, a different set of variables describes the element and the overlay theme: --sl-w, --sl-h, --sl-primary and the other theme colors, --sl-font-heading and --sl-font-body. See Environment.

Example: a neon alert

This restyles an alert with a rounded neon border, gradient headline text and a gentle float. The same steps work for any element.

  1. Open your overlay in Media → Overlays. If you don't have an alert yet, see Alerts.
  2. Select the alert on the canvas or in the layers list. Alerts are hidden in the design, so it's drawn faded, but you can still select it.
  3. In the inspector, open the CSS section and switch on Show on canvas to see the parts.
  4. Click box under Parts and fill in the rule:

    [data-part="box"] {
      border: 3px solid #4df3ff;
      border-radius: 28px;
      box-shadow: 0 0 24px #4df3ff, inset 0 0 18px rgb(77 243 255 / 0.35);
    }
    
  5. Click the Gradient text starter (it targets headline) and change the colors if you like:

    [data-part="headline"] {
      background: linear-gradient(90deg, #ff5cf0, #5c93ff);
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
    }
    
  6. Click Gentle float, and widen the gap between headline and message with the alert's variable:

    @keyframes float {
      50% { transform: translateY(-6px); }
    }
    :scope {
      animation: float 3s ease-in-out infinite;
      --sl-gap-text: 18px;
    }
    
  7. Click Alerts in the toolbar, then Test next to the alert's trigger. The alert plays on stream with sample values, restyled. Change a value and test again; edits reach OBS at once.

Troubleshooting

Symptom Fix
The box says the CSS isn't allowed Check it against What's allowed. A url() must start with /media/; remove backslash escapes.
A property is underlined in amber Hover it: it's probably misspelt, and the message suggests the right name. If the name is right, the property is only newer than CroStream's web view, and the CSS is applied anyway: check the result in OBS.
A rule has no effect The setting is probably inline. Use the element's variable, or add !important.
A rule styles nothing Check the part name: switch on Show on canvas and hover the piece. Parts only exist on built-in elements; inside a custom widget, use the widget's own classes.
An animation doesn't run Make sure the @keyframes is in the same box as the rule that uses it.
A custom widget's shadow looks doubled Element CSS applies outside and inside a custom widget's frame; see On a custom widget.