Writing HTML & CSS for Discourse
Discourse styles must survive conditions the author never sees: a theme restyling the
component, light/dark color schemes, any viewport width, and screen-reader users
navigating the markup. A component is correct only when it holds up across all of them.
Two CSS rules are the most load-bearing — get them right by reflex:
- Name classes with BEM so themes can target and override cleanly.
- Never hardcode color — pull from the CSS custom-property palette so themes and dark
mode work for free.
These rules operationalize Discourse's documented frontend philosophy — mobile-first,
progressive enhancement (works without hover or JS), a themeable base layer, and a shared design
system over bespoke styling. The two source-of-truth docs are
26-css-guidelines-bem.md
(naming) and
28-designing-for-devices.md
(responsive / device adaptation). The canonical real-world example is the chat loading skeleton —
plugins/chat/assets/javascripts/discourse/components/chat-skeleton.gjs
and its .scss.
Deeper detail lives in companion files — read the relevant one before working in that area:
- references/color-and-theming.md — full palette, semantic
tokens,
--d-* design vars.
- references/layout-and-responsive.md — intrinsic layout
and the
lib/viewport breakpoint API.
- references/css-authoring.md — native-CSS-vs-SASS swaps, local
custom properties (incl. theme interaction), shared mixins, file organization, and buttons.
- references/css-repair.md — repairing existing CSS: stale selector
deletion, selector scoping, overflow fixes, FormKit/token migration, mobile/desktop cleanup,
and regression verification.
- references/accessibility.md — screen-reader-only text, live-region
announcements, contrast & forced-colors detail (the short a11y rules stay inline below).
BEM naming (block / element / modifier)
Discourse uses a modified BEM: standard block__element, but modifiers are standalone
classes, not block__element--modifier suffixes.
| Part |
Syntax |
Example |
| Block |
.block |
.chat-skeleton, .d-button |
| Element |
.block__element |
.chat-skeleton__message, .header__item |
| Modifier |
.--modifier (standalone) |
.--cancel, .--animation, .--error |
| State |
.is-foo / .has-foo |
.is-open, .has-errors |
- One block per reusable component. A distinct block-level class per Ember component, then
hang elements and modifiers off it. Blocks may nest inside blocks.
- An element is a part with no meaning outside its block. Elements do not chain
(
block__el1__el2 is wrong — use block__el2); the skeleton uses flat __message,
__message-avatar, __message-text.
- A modifier is a standalone
.--modifier for appearance variants (not the verbose
block__element--modifier) — they're often reused, and it keeps the DOM readable.
- State prefixes
is-/has- mark a condition driven by JS or interaction (is-open,
has-errors), as opposed to a design variant (--cancel).
- Prefer adding a class over the CSS
:has() selector. If a component already knows its own
state, express it with a class (is-open, a --modifier) rather than :has(), which can be
costly (re-evaluated on DOM mutations; broad/nested selectors are worst). Reserve :has() for
when you can't add a class — e.g. styling a parent off cooked/third-party markup — and scope it
tightly.
Dash convention
Use two dashes: .--modifier. This is the documented standard and dominates the codebase.
Legacy single-dash modifiers exist (.-animation in the chat-skeleton predates the
convention) — don't copy them in new code, and don't mass-rename existing ones unless that's
the task.
Name by meaning, not appearance
Class names describe what a thing is, never how it looks — a presentational name
becomes a lie the moment a theme, redesign, or responsive reflow changes the appearance, and
you can't rename it without hunting down every override. Avoid:
- Position —
block-right → block__sidebar, block__actions.
- Color —
warning-red / text-blue → block--warning, block__link.
- Size —
box-300px, text-large → block__panel, --prominent.
Same for modifiers: .--danger / .--compact (intent), not .--red / .--narrow
(appearance).
Don't build class names from user input
Never interpolate a user-controlled value (group/category/tag name, username, custom field)
directly into a class — they collide with generic utility/state classes (a group named "hidden"
emits class="hidden" and silently inherits its rules, often display: none) and make
unpredictable selectors. Carry the value in a data attribute and target it with an attribute
selector:
{{! BAD — a group named "hidden" becomes class="hidden" }}
<span class="group-badge {{@group.name}}">…</span>
{{! GOOD — namespaced in an attribute, can't collide }}
<span class="group-badge" data-group-name={{@group.name}}>…</span>
.group-badge[data-group-name="staff"] { color: var(--tertiary); }
If a class is genuinely required (an existing theme hook), prefix it (group-#{name},
category-#{slug}) and prefer slugs over free-text. These values still need normal escaping
for safety — see the XSS note under HTML conventions.
Nesting & modifier application
Nest elements under the block with SCSS &. A modifier can apply directly on an element
(&.--modifier) or indirectly from an ancestor (.--modifier &) — the latter keeps the
DOM clean when many children react to one condition (e.g. one --error on the block):
.composer {
&__input {
&.--disabled { … } // <input class="composer__input --disabled">
.--error & { border-color: var(--danger); } // <div class="composer --error"> … </div>
}
}
Color & theming — never hardcode
Do not write hex, rgb(), or named colors for UI surfaces, text, or borders. Use the CSS
custom-property palette so the result adapts to every theme and color scheme:
// BAD — breaks theming and dark mode
.notice { color: #222; background: #fff; border: 1px solid #ddd; }
// GOOD — adapts to every theme and color scheme
.notice { color: var(--primary); background: var(--secondary); border: 1px solid var(--primary-low); }
Do not author a separate dark-mode block. The palette already inverts; if something looks
wrong in dark mode you picked the wrong palette variable, not the wrong color.
Prefer the semantic --token-color-* tokens for standard UI (text, surfaces, borders,
icons); reach into the raw palette for bespoke components a token doesn't cover. Most-used
palette vars: --primary (text/foreground, with -low…-high and -100…-900 steps),
--secondary (background), --tertiary (accent/links), --danger/--success, and
rgba(var(--x-rgb), …) for translucency. Full palette, tokens, and --d-* design vars:
references/color-and-theming.md.
Don't rely on color alone, and mind contrast. Never signal state or meaning by color by
itself (a red border for an error, a green dot for "online") — pair it with an icon, text, or
shape so it's perceivable to colorblind users and in forced-colors mode. Stick to the palette's
intended foreground/background pairings (text in --primary on a --secondary surface, etc.),
which are contrast-tuned per scheme; don't invent low-contrast combinations like --primary-low
text on --secondary. WCAG AA targets and forced-colors/WHCM notes:
references/accessibility.md.
Style with restraint
Discourse is a highly themeable platform: core and plugin styles are a base that theme
authors build on, and anything you over-style is something they then have to override or
undo. Aim for the minimum that makes a component clear and functional, and leave the aesthetics
to themes.
- Style for structure and function, not decoration. Layout, spacing, sizing, and states
(hover/focus/disabled) — yes. Decorative flourishes that aren't core to the component's
meaning (drop shadows, gradients, custom borders, bespoke typography) are opinions a theme may
not share — leave them out.
- When a visual choice isn't load-bearing, it probably belongs in a theme, not core. A
plainer component a theme can dress up beats a heavily-styled one a theme must strip down. When
in doubt, do less.
- It's the why behind several rules here — palette/tokens over fixed values, low specificity,
override hooks (
...attributes, local --custom-properties) — so themes can adjust without
fighting your CSS.
Browser support
Discourse targets the latest stable releases of Edge, Chrome, Firefox, and Safari
(including iOS 16.4+) — no IE, no legacy polyfills. Use modern CSS freely; the practical floor
is the oldest still-"latest-stable" Safari, so for a very new feature confirm Safari support
(Baseline "widely available" is a safe bar).
Native CSS first
Discourse is gradually moving toward native CSS — when a native feature does the job, prefer it
over a compile-time SASS construct (var(--…) over $variables, clamp() over sass:math,
light-dark() over SCSS color functions, var(--font-up-2) over the $font-up-2 alias).
But keep the established helpers — z("header"), the lib/viewport mixins, & nesting.
Full swap list + rule-of-thumb: references/css-authoring.md.
CSS best practices
Keep specificity low. Target by one class, not deep descendant chains
(.card__title, not .card .body h2). Don't style by ID or over-qualify (div.card →
.card). Avoid !important — it usually signals a specificity fight you can solve by
simplifying the selector. When it's genuinely necessary (overriding inline styles or a
third-party rule), always add a comment saying why.
Units & flexible sizing. Prefer em/rem over px so the UI scales with the user's
adjustable base font size (px is fine for hairline borders). Avoid fixed heights/magic
dimensions — let content size the box (translated strings and long usernames run longer than
English); prefer min-/max- over hard height/width. Use gap for flex/grid spacing,
not per-child margins. On user-generated text (titles, usernames, URLs), add
overflow-wrap: anywhere so a long unbroken string can't force horizontal scroll.
Local custom properties. Hoist a value to a component-scoped --property when it's reused
or feeds a calc() (the name documents the math better than a magic number). Don't promote
every value reflexively. Full pattern + theme interaction:
references/css-authoring.md.
Right-to-left: use logical properties. Write margin-inline, padding-inline,
inset-inline-start/-end, border-start-*, text-align: start/end — not left/right
or margin-left. New code defaults to these and avoids a separate _rtl.scss. Legacy code
uses physical props + _rtl.scss; don't mass-convert, but don't add new physical-direction
rules either.
Motion & focus (a11y). Gate non-essential animation behind
@media (prefers-reduced-motion: no-preference) (the chat-skeleton shimmer does this). Animate
cheap properties — transform and opacity are GPU-composited; animating layout
properties (width, height, top/left, margin) triggers reflow and causes jank. Never
outline: none without a replacement — use :focus-visible so keyboard users get a clear
ring while it stays hidden for mouse clicks.
Reuse the shared mixins (common/foundation/mixins.scss): ellipsis / line-clamp($n)
for truncation, d-animation (bakes in reduced-motion), unselectable. Details and the
legacy ones to skip: references/css-authoring.md.
Repairing existing CSS
When modifying existing Discourse CSS, prefer removing or narrowing over adding another
override. Most CSS regressions come from stale selectors, broad shared rules, old mobile/desktop
splits, or component architecture changing underneath a stylesheet.
Before writing new CSS, check where the selector is used and whether it is still rendered:
rg "<class-or-selector>" app/assets/stylesheets plugins themes
git log --oneline --since='2026-01-01' -- '*.scss' '*.css' --grep='fix|scope|selector|overflow|mobile|formkit|token|foundation|remove'
git show --stat --patch <suspect-commit> -- '*.scss' '*.css'
Preferred repair moves
Scope broad selectors down. Do not fix leakage by adding !important or deeper descendant
chains. If .name, .num, .btn, .d-icon, .select-kit, td, or th leaks, target the
real component/state: .selected-name .name, .topic-list-data.num,
.sidebar-filter__clear.
Delete stale CSS and imports. If a component/class was removed or replaced, remove its
stylesheet/imports rather than keeping compatibility ghosts. Check with rg before assuming a
selector still matters.
Move device-specific rules into common/ with viewport mixins. New and repaired styles
should live in one responsive stylesheet using @include viewport.from(...) /
@include viewport.until(...), not split desktop/ and mobile/ copies.
Fix overflow with containment primitives. Try min-width: 0, minmax(0, 1fr),
max-width: 100%, max-height: 100%, overflow: hidden, flex-wrap: wrap,
table-layout: fixed, and @include ellipsis before adding magic widths.
Put scroll on the owning container, not html/body. Especially on iOS, body scrolling
fixes usually create flicker or broken fixed layouts. Identify the route/modal/panel that
should scroll and give that container the height/overflow.
Use FormKit/select-kit APIs and tokens instead of global internal overrides. Prefer
FormKit field/container modifiers and --form-kit-* variables. Avoid broad rules like
.form-kit__container-content { width: 100%; } outside FormKit itself.
Avoid global DOM inference. Be suspicious of body:has(...), html { overflow-y: scroll; },
li:last-child for dynamic lists, and component-only variables placed in :root. If the app
knows the state, render a class/state/modifier.
Audit shared foundation changes. Changes to .btn, .select-kit, .d-icon,
.topic-list-data, category/tag badges, inputs, or foundation variables affect plugins and
themes. Check chat, reactions, solved, topic voting, Data Explorer, admin, Horizon, mobile,
and RTL where relevant.
Red flags
Stop and re-check if your patch adds:
!important
body:has(...)
html { overflow-y: scroll; }
:root { --one-component-var: ... }
width: 340px;
min-width: 300px;
left: ...; right: ...; // without RTL thought
li:last-child
.name { ... }
.num { ... }
.btn { ... }
These are not banned, but they are radioactive enough to need a clear reason.
Verification for CSS repair PRs
Check the affected surface in:
- desktop and mobile viewports
- light and dark palettes
- Horizon if header/sidebar/foundation/theme variables are touched
- RTL if physical positioning, icons, scroll fades, or nav is touched
- iOS Safari / iOS-like behavior for scroll/chat/composer fixes
- FormKit/select-kit contexts when forms or choosers are touched
- plugin surfaces sharing common foundation classes
- stale imports after deleting CSS
For visual UX changes, include before/after screenshots. Deep-dive repair patterns and examples:
references/css-repair.md.
HTML / template conventions
Discourse templates are .gjs (Glimmer components with inline <template>) or .hbs.
Escape by default. Use {{value}} (escaped). Never {{{value}}} / triple-curlies or raw
innerHTML for user-derived content — that's an XSS hole. Trusted HTML must be explicitly
marked (trustHTML / htmlSafe) and only for content you control.
Icons come from the dIcon helper, never inline SVG or <i class="fa">:
import dIcon from "discourse/ui-kit/helpers/d-icon";
// …in <template>: {{dIcon "chevron-left"}}
Use a real icon name — icons render from Discourse's registered SVG sprite (a subset of
Font Awesome), not arbitrary names. Don't guess; if a plugin needs an icon outside the subset,
register it (register_svg_icon in plugin.rb).
Icon-only controls need an accessible label. An icon conveys nothing to a screen reader,
so a control with only an icon must carry a label: on <DButton> use @title (an i18n key —
also a tooltip) or @ariaLabel, or @translatedTitle for pre-translated text; on raw markup,
a translated aria-label. A button with visible text doesn't need this. (dIcon renders the
glyph aria-hidden by default — the accessible name belongs on the control, not the icon.)
Screen-reader-only text uses .sr-only, not display: none. For text that should exist
for assistive tech but not show on screen (a label for an icon-only region, a skip target), use
the .sr-only helper — display: none/visibility: hidden remove it from the accessibility
tree. See references/accessibility.md.
Announce dynamic content via the a11y service — never a hand-rolled aria-live. Content
that appears without a page navigation (async results, a toast, inline validation) needs
this.a11y.announce(message, "polite" | "assertive") to be read out. Live regions only work
when persistent in the DOM before the change — which is exactly why you route through the
service rather than adding an aria-live element alongside the new content. Details and the
why: references/accessibility.md.
All display strings are translatable. Pull copy through i18n(...); never hardcode
user-facing English. Use placeholders for interpolation — never concatenate translated
fragments. Write strings in "Sentence case".
Semantic, accessible markup. Reach for the element that describes the content before a
generic <div>/<span>:
- Landmarks & sectioning —
<nav>, <header>/<footer>, <main>, <aside>,
<section>/<article> expose landmarks and an outline screen-reader users navigate by; a
wall of <div>s gives them nothing to jump between. <ul>/<ol> + <li> for lists,
<table> only for tabular data.
- Interactive & form — real
<button> for actions (not a clickable <div>), <a> for
navigation, <label> tied to its input, <fieldset>/<legend> for groups.
- Add
alt/aria-* only to fill gaps native semantics can't — don't paper over a wrong
element with ARIA. And don't add <section>/<nav> purely as styling hooks where they
carry no role; a <div> is honest there.
- Prefer existing
<DButton> and other shared components — they get semantics and a11y right.
Buttons: <button> for actions, <a> for navigation — then one standalone variant. Choose
the element by behavior (anything that changes the URL is a link), not looks. A button-looking
control needs .btn plus exactly one mutually-exclusive variant (btn-default,
btn-primary, btn-danger, btn-flat/btn-transparent); <DButton> adds .btn for you, so
pass the variant via @class. Only controls that look and function like a standard button
get these classes — a <button> inside a dropdown, menu, tab, or list row is styled by its
own component and must not get a .btn-* variant. Full guidance:
references/css-authoring.md.
Use FormKit for forms — don't roll your own. Build forms with the <Form> component
(import Form from "discourse/components/form"), which yields field/row/submit pieces
(<form.Field>, <form.Row>, <form.Submit>) and handles layout, validation, state, and the
label/error/a11y wiring for you. Don't hand-assemble a raw <form> with manual <input>s and
bespoke validation. See
docs/developer-guides/docs/03-code-internals/22-form-kit.md
(frontend/discourse/app/form-kit).
Splat ...attributes on the component's root element so a caller can pass a class,
data-*, aria-*, or a --modifier through. Without it the component is a closed box. The
root is also where the BEM block class lives: <div class="user-card" ...attributes>.
Use dConcatClass for conditional/computed classes instead of hand-built strings or
stacked inline {{if}}s (import dConcatClass from "discourse/ui-kit/helpers/d-concat-class").
It drops falsy values cleanly:
<div class={{dConcatClass "card" (if @selected "is-selected") (if @compact "--compact")}}>
Know <PluginOutlet>, but don't add outlets speculatively. Outlets are named seams where
plugins/themes inject content (400+ across the app); you'll work inside them often. Each one is
a public API surface and maintenance commitment — once it exists, extensions depend on its
name and @outletArgs, so it can't be moved freely. Add one only for a concrete need; pass
data via lazyHash (not hash) and name it by location (above-…, below-…). See
14-plugin-outlet-connectors.md.
Heading levels follow the document outline, not type size. Never pick a level for its
default font size — if the right heading looks wrong-sized, style it in CSS
(font-size: var(--font-up-1)). An <h1> styled smaller is fine; an <h3> chosen because
you wanted smaller text is not.
Avoid "div-itis" — if you can't name what a wrapper does, drop it. The test for every
wrapping element: state its layout or semantic job in a few words (its own max-width, a
positioning context, a scroll area, a flex/grid container, a real semantic region). If you
can't, delete it and let the child stand on its own. A lone <button> wrapped in a <div>, or
two or three nested <div>s that just pass content straight through, are the usual offenders —
the markup carries weight it doesn't earn. Pick the right element too (<span> inline, <div>
for a block/structural container, a semantic element where one fits). Beyond clutter, a stray
wrapper between a flex/grid parent and its children breaks layout — the items stop being
direct children, so gap/flex/grid-template no longer reach them. No style, role, or layout
reason → delete it.
Components clean up after themselves — don't render empty containers. If a container's
contents are conditional, put the container inside the condition so it isn't emitted when
empty — an empty-but-present element still counts as a flex/grid item and gap slot, leaving
a phantom gap:
{{! GOOD — nothing emitted when there's nothing to show }}
{{#if @actions}}
<div class="card__actions">
{{#each @actions as |action|}}<DButton @action={{action}} />{{/each}}
</div>
{{/if}}
Likewise, don't put padding/margin/gap on a container that can render empty — that
reserves space with no content. And don't lean on :empty to hide it: Ember leaves
whitespace/comment nodes (<!---->) that make :empty fail to match, so it silently won't
apply. The template conditional is the only reliable guard.
No empty backing class for a template-only component unless explicitly requested.
Don't add JSDoc to new code; if editing code that already has it, keep it accurate.
Where stylesheets live
Core stylesheets are under app/assets/stylesheets/. Place a partial by target, then register
it in the matching _index.scss / parent @import (partials are underscore-prefixed and
not auto-globbed).
| Path |
Applies to |
common/base/ |
Where new styles go — one responsive stylesheet for all viewports |
common/components/ |
Reusable component styles |
desktop/ |
Legacy desktop-only — don't add new styles here |
mobile/ |
Legacy mobile-only — don't add new styles here |
*_rtl.scss |
Legacy RTL overrides — new code uses logical properties instead |
- Write one responsive stylesheet, not desktop + mobile copies. Discourse designs
mobile-first and enhances upward (see the philosophy doc,
28-designing-for-devices.md):
new styles live in common/ and adapt with breakpoints. Prefer intrinsic layout (e.g.
grid-template-columns: repeat(auto-fill, minmax(14em, 1fr))) and reach for a breakpoint only
to restructure; use the lib/viewport mixins (viewport.from/until/between). The legacy
device split — the desktop//mobile/ dirs, the .mobile-view/.desktop-view classes, and
site.mobileView in JS — is deprecated; don't use it. Details, breakpoints, and the
capabilities service: references/layout-and-responsive.md.
- Design to work without hover. Touch users can't hover, so hover is an enhancement, not a
requirement — nothing essential should be hover-only. When you do add hover styling, scope it
to
html.discourse-no-touch (see the layout reference).
common/foundation/variables.scss and mixins.scss are injected everywhere — that's where
layout-width vars and z() come from. (Font sizes/line-heights are native custom properties —
var(--font-up-2), var(--line-height-medium).)
Plugins & themes
- Plugin styles live in
plugins/<name>/assets/stylesheets/ and are registered in
plugin.rb: register_asset "stylesheets/common/my-feature.scss" (optionally , :desktop /
, :admin).
- Themes/components ship
common//desktop//mobile/ SCSS compiled with the palette
injected — the same var(--…) and $… variables are available, so color, BEM, and native-CSS
rules apply identically. The same responsive-first rule holds: put new styles in common/.
Before committing
Lint every changed file (CSS via stylelint, templates via the JS toolchain):
bin/lint --fix path/to/file.scss path/to/file.gjs
bin/lint --fix --recent # all recently changed files
1---2name: discourse-writing-html-css3description: Write and repair HTML/CSS/SCSS for Discourse core, plugins, themes, and theme components. Use when authoring or modifying templates (.gjs/.hbs), stylesheets (.scss), component markup, class names, responsive layout, FormKit/select-kit styling, or CSS regressions. Covers Discourse's BEM-with-standalone-modifiers naming, the CSS custom-property color palette (theming + dark mode), template/HTML conventions, CSS repair patterns, and where stylesheets live.4---56# Writing HTML & CSS for Discourse78Discourse styles must survive conditions the author never sees: a **theme** restyling the9component, **light/dark color schemes**, any **viewport** width, and **screen-reader** users10navigating the markup. A component is correct only when it holds up across all of them.1112Two CSS rules are the most load-bearing — get them right by reflex:13141. **Name classes with BEM** so themes can target and override cleanly.152. **Never hardcode color** — pull from the CSS custom-property palette so themes and dark16 mode work for free.1718These rules operationalize Discourse's documented frontend philosophy — **mobile-first,19progressive enhancement (works without hover or JS), a themeable base layer, and a shared design20system over bespoke styling.** The two source-of-truth docs are21[`26-css-guidelines-bem.md`](../../docs/developer-guides/docs/03-code-internals/26-css-guidelines-bem.md)22(naming) and23[`28-designing-for-devices.md`](../../docs/developer-guides/docs/03-code-internals/28-designing-for-devices.md)24(responsive / device adaptation). The canonical real-world example is the chat loading skeleton —25[`plugins/chat/assets/javascripts/discourse/components/chat-skeleton.gjs`](../../plugins/chat/assets/javascripts/discourse/components/chat-skeleton.gjs)26and its `.scss`.2728**Deeper detail lives in companion files — read the relevant one before working in that area:**2930- [references/color-and-theming.md](references/color-and-theming.md) — full palette, semantic31 tokens, `--d-*` design vars.32- [references/layout-and-responsive.md](references/layout-and-responsive.md) — intrinsic layout33 and the `lib/viewport` breakpoint API.34- [references/css-authoring.md](references/css-authoring.md) — native-CSS-vs-SASS swaps, local35 custom properties (incl. theme interaction), shared mixins, file organization, and buttons.36- [references/css-repair.md](references/css-repair.md) — repairing existing CSS: stale selector37 deletion, selector scoping, overflow fixes, FormKit/token migration, mobile/desktop cleanup,38 and regression verification.39- [references/accessibility.md](references/accessibility.md) — screen-reader-only text, live-region40 announcements, contrast & forced-colors detail (the short a11y rules stay inline below).4142## BEM naming (block / element / modifier)4344Discourse uses a **modified BEM**: standard `block__element`, but modifiers are **standalone45classes**, not `block__element--modifier` suffixes.4647| Part | Syntax | Example |48| --- | --- | --- |49| Block | `.block` | `.chat-skeleton`, `.d-button` |50| Element | `.block__element` | `.chat-skeleton__message`, `.header__item` |51| Modifier | `.--modifier` (standalone) | `.--cancel`, `.--animation`, `.--error` |52| State | `.is-foo` / `.has-foo` | `.is-open`, `.has-errors` |5354- **One block per reusable component.** A distinct block-level class per Ember component, then55 hang elements and modifiers off it. Blocks may nest inside blocks.56- **An element** is a part with no meaning outside its block. Elements do **not** chain57 (`block__el1__el2` is wrong — use `block__el2`); the skeleton uses flat `__message`,58 `__message-avatar`, `__message-text`.59- **A modifier** is a standalone `.--modifier` for appearance variants (not the verbose60 `block__element--modifier`) — they're often reused, and it keeps the DOM readable.61- **State prefixes** `is-`/`has-` mark a condition driven by JS or interaction (`is-open`,62 `has-errors`), as opposed to a design variant (`--cancel`).63- **Prefer adding a class over the CSS `:has()` selector.** If a component already knows its own64 state, express it with a class (`is-open`, a `--modifier`) rather than `:has()`, which can be65 costly (re-evaluated on DOM mutations; broad/nested selectors are worst). Reserve `:has()` for66 when you can't add a class — e.g. styling a parent off cooked/third-party markup — and scope it67 tightly.6869### Dash convention7071Use **two dashes**: `.--modifier`. This is the documented standard and dominates the codebase.72Legacy **single-dash** modifiers exist (`.-animation` in the chat-skeleton predates the73convention) — don't copy them in new code, and don't mass-rename existing ones unless that's74the task.7576### Name by meaning, not appearance7778Class names describe **what a thing is**, never **how it looks** — a presentational name79becomes a lie the moment a theme, redesign, or responsive reflow changes the appearance, and80you can't rename it without hunting down every override. Avoid:8182- **Position** — `block-right` → `block__sidebar`, `block__actions`.83- **Color** — `warning-red` / `text-blue` → `block--warning`, `block__link`.84- **Size** — `box-300px`, `text-large` → `block__panel`, `--prominent`.8586Same for modifiers: `.--danger` / `.--compact` (intent), not `.--red` / `.--narrow`87(appearance).8889### Don't build class names from user input9091Never interpolate a user-controlled value (group/category/tag name, username, custom field)92directly into a class — they collide with generic utility/state classes (a group named "hidden"93emits `class="hidden"` and silently inherits its rules, often `display: none`) and make94unpredictable selectors. Carry the value in a **data attribute** and target it with an attribute95selector:9697```hbs98{{! BAD — a group named "hidden" becomes class="hidden" }}99<span class="group-badge {{@group.name}}">…</span>100101{{! GOOD — namespaced in an attribute, can't collide }}102<span class="group-badge" data-group-name={{@group.name}}>…</span>103```104105```scss106.group-badge[data-group-name="staff"] { color: var(--tertiary); }107```108109If a class is genuinely required (an existing theme hook), **prefix it** (`group-#{name}`,110`category-#{slug}`) and prefer slugs over free-text. These values still need normal escaping111for safety — see the XSS note under HTML conventions.112113### Nesting & modifier application114115Nest elements under the block with SCSS `&`. A modifier can apply **directly** on an element116(`&.--modifier`) or **indirectly** from an ancestor (`.--modifier &`) — the latter keeps the117DOM clean when many children react to one condition (e.g. one `--error` on the block):118119```scss120.composer {121 &__input {122 &.--disabled { … } // <input class="composer__input --disabled">123 .--error & { border-color: var(--danger); } // <div class="composer --error"> … </div>124 }125}126```127128## Color & theming — never hardcode129130**Do not write hex, `rgb()`, or named colors for UI surfaces, text, or borders.** Use the CSS131custom-property palette so the result adapts to every theme and color scheme:132133```scss134// BAD — breaks theming and dark mode135.notice { color: #222; background: #fff; border: 1px solid #ddd; }136137// GOOD — adapts to every theme and color scheme138.notice { color: var(--primary); background: var(--secondary); border: 1px solid var(--primary-low); }139```140141**Do not author a separate dark-mode block.** The palette already inverts; if something looks142wrong in dark mode you picked the wrong palette variable, not the wrong color.143144**Prefer the semantic `--token-color-*` tokens for standard UI** (text, surfaces, borders,145icons); reach into the raw palette for bespoke components a token doesn't cover. Most-used146palette vars: `--primary` (text/foreground, with `-low`…`-high` and `-100`…`-900` steps),147`--secondary` (background), `--tertiary` (accent/links), `--danger`/`--success`, and148`rgba(var(--x-rgb), …)` for translucency. Full palette, tokens, and `--d-*` design vars:149[references/color-and-theming.md](references/color-and-theming.md).150151**Don't rely on color alone, and mind contrast.** Never signal state or meaning by color by152itself (a red border for an error, a green dot for "online") — pair it with an icon, text, or153shape so it's perceivable to colorblind users and in forced-colors mode. Stick to the palette's154intended foreground/background pairings (text in `--primary` on a `--secondary` surface, etc.),155which are contrast-tuned per scheme; don't invent low-contrast combinations like `--primary-low`156text on `--secondary`. WCAG AA targets and forced-colors/WHCM notes:157[references/accessibility.md](references/accessibility.md).158159## Style with restraint160161Discourse is a highly themeable platform: core and plugin styles are a **base that theme162authors build on**, and anything you over-style is something they then have to override or163undo. Aim for the minimum that makes a component clear and functional, and leave the aesthetics164to themes.165166- **Style for structure and function, not decoration.** Layout, spacing, sizing, and states167 (hover/focus/disabled) — yes. Decorative flourishes that aren't core to the component's168 meaning (drop shadows, gradients, custom borders, bespoke typography) are opinions a theme may169 not share — leave them out.170- **When a visual choice isn't load-bearing, it probably belongs in a theme, not core.** A171 plainer component a theme can dress up beats a heavily-styled one a theme must strip down. When172 in doubt, do less.173- It's the *why* behind several rules here — palette/tokens over fixed values, low specificity,174 override hooks (`...attributes`, local `--custom-properties`) — so themes can adjust without175 fighting your CSS.176177## Browser support178179Discourse targets the **latest stable releases** of Edge, Chrome, Firefox, and Safari180(including iOS 16.4+) — no IE, no legacy polyfills. Use modern CSS freely; the practical floor181is the oldest still-"latest-stable" Safari, so for a very new feature confirm Safari support182(Baseline "widely available" is a safe bar).183184## Native CSS first185186Discourse is gradually moving toward native CSS — when a native feature does the job, prefer it187over a compile-time SASS construct (`var(--…)` over `$variables`, `clamp()` over `sass:math`,188`light-dark()` over SCSS color functions, `var(--font-up-2)` over the `$font-up-2` alias).189**But keep the established helpers** — `z("header")`, the `lib/viewport` mixins, `&` nesting.190Full swap list + rule-of-thumb: [references/css-authoring.md](references/css-authoring.md).191192## CSS best practices193194- **Keep specificity low.** Target by **one class**, not deep descendant chains195 (`.card__title`, not `.card .body h2`). Don't style by ID or over-qualify (`div.card` →196 `.card`). **Avoid `!important`** — it usually signals a specificity fight you can solve by197 simplifying the selector. When it's genuinely necessary (overriding inline styles or a198 third-party rule), always add a comment saying why.199200- **Units & flexible sizing.** Prefer `em`/`rem` over `px` so the UI scales with the user's201 adjustable base font size (`px` is fine for hairline borders). Avoid fixed heights/magic202 dimensions — let content size the box (translated strings and long usernames run longer than203 English); prefer `min-`/`max-` over hard `height`/`width`. Use **`gap`** for flex/grid spacing,204 not per-child margins. On user-generated text (titles, usernames, URLs), add205 `overflow-wrap: anywhere` so a long unbroken string can't force horizontal scroll.206207- **Local custom properties.** Hoist a value to a component-scoped `--property` when it's reused208 or feeds a `calc()` (the name documents the math better than a magic number). Don't promote209 every value reflexively. Full pattern + theme interaction:210 [references/css-authoring.md](references/css-authoring.md).211212- **Right-to-left: use logical properties.** Write `margin-inline`, `padding-inline`,213 `inset-inline-start`/`-end`, `border-start-*`, `text-align: start`/`end` — not `left`/`right`214 or `margin-left`. New code defaults to these and avoids a separate `_rtl.scss`. Legacy code215 uses physical props + `_rtl.scss`; don't mass-convert, but don't add new physical-direction216 rules either.217218- **Motion & focus (a11y).** Gate non-essential animation behind219 `@media (prefers-reduced-motion: no-preference)` (the chat-skeleton shimmer does this). Animate220 **cheap properties** — `transform` and `opacity` are GPU-composited; animating layout221 properties (`width`, `height`, `top`/`left`, `margin`) triggers reflow and causes jank. Never222 `outline: none` without a replacement — use **`:focus-visible`** so keyboard users get a clear223 ring while it stays hidden for mouse clicks.224225- **Reuse the shared mixins** (`common/foundation/mixins.scss`): `ellipsis` / `line-clamp($n)`226 for truncation, `d-animation` (bakes in reduced-motion), `unselectable`. Details and the227 legacy ones to skip: [references/css-authoring.md](references/css-authoring.md).228229## Repairing existing CSS230231When modifying existing Discourse CSS, prefer **removing or narrowing** over adding another232override. Most CSS regressions come from stale selectors, broad shared rules, old mobile/desktop233splits, or component architecture changing underneath a stylesheet.234235Before writing new CSS, check where the selector is used and whether it is still rendered:236237```sh238rg "<class-or-selector>" app/assets/stylesheets plugins themes239git log --oneline --since='2026-01-01' -- '*.scss' '*.css' --grep='fix|scope|selector|overflow|mobile|formkit|token|foundation|remove'240git show --stat --patch <suspect-commit> -- '*.scss' '*.css'241```242243### Preferred repair moves244245- **Scope broad selectors down.** Do not fix leakage by adding `!important` or deeper descendant246 chains. If `.name`, `.num`, `.btn`, `.d-icon`, `.select-kit`, `td`, or `th` leaks, target the247 real component/state: `.selected-name .name`, `.topic-list-data.num`,248 `.sidebar-filter__clear`.249250- **Delete stale CSS and imports.** If a component/class was removed or replaced, remove its251 stylesheet/imports rather than keeping compatibility ghosts. Check with `rg` before assuming a252 selector still matters.253254- **Move device-specific rules into `common/` with viewport mixins.** New and repaired styles255 should live in one responsive stylesheet using `@include viewport.from(...)` /256 `@include viewport.until(...)`, not split `desktop/` and `mobile/` copies.257258- **Fix overflow with containment primitives.** Try `min-width: 0`, `minmax(0, 1fr)`,259 `max-width: 100%`, `max-height: 100%`, `overflow: hidden`, `flex-wrap: wrap`,260 `table-layout: fixed`, and `@include ellipsis` before adding magic widths.261262- **Put scroll on the owning container, not `html`/`body`.** Especially on iOS, body scrolling263 fixes usually create flicker or broken fixed layouts. Identify the route/modal/panel that264 should scroll and give that container the height/overflow.265266- **Use FormKit/select-kit APIs and tokens instead of global internal overrides.** Prefer267 FormKit field/container modifiers and `--form-kit-*` variables. Avoid broad rules like268 `.form-kit__container-content { width: 100%; }` outside FormKit itself.269270- **Avoid global DOM inference.** Be suspicious of `body:has(...)`, `html { overflow-y: scroll; }`,271 `li:last-child` for dynamic lists, and component-only variables placed in `:root`. If the app272 knows the state, render a class/state/modifier.273274- **Audit shared foundation changes.** Changes to `.btn`, `.select-kit`, `.d-icon`,275 `.topic-list-data`, category/tag badges, inputs, or foundation variables affect plugins and276 themes. Check chat, reactions, solved, topic voting, Data Explorer, admin, Horizon, mobile,277 and RTL where relevant.278279### Red flags280281Stop and re-check if your patch adds:282283```scss284!important285body:has(...)286html { overflow-y: scroll; }287:root { --one-component-var: ... }288width: 340px;289min-width: 300px;290left: ...; right: ...; // without RTL thought291li:last-child292.name { ... }293.num { ... }294.btn { ... }295```296297These are not banned, but they are radioactive enough to need a clear reason.298299### Verification for CSS repair PRs300301Check the affected surface in:302303- desktop and mobile viewports304- light and dark palettes305- Horizon if header/sidebar/foundation/theme variables are touched306- RTL if physical positioning, icons, scroll fades, or nav is touched307- iOS Safari / iOS-like behavior for scroll/chat/composer fixes308- FormKit/select-kit contexts when forms or choosers are touched309- plugin surfaces sharing common foundation classes310- stale imports after deleting CSS311312For visual UX changes, include before/after screenshots. Deep-dive repair patterns and examples:313[references/css-repair.md](references/css-repair.md).314315## HTML / template conventions316317Discourse templates are **`.gjs`** (Glimmer components with inline `<template>`) or `.hbs`.318319- **Escape by default.** Use `{{value}}` (escaped). Never `{{{value}}}` / triple-curlies or raw320 `innerHTML` for user-derived content — that's an XSS hole. Trusted HTML must be explicitly321 marked (`trustHTML` / `htmlSafe`) and only for content you control.322- **Icons** come from the `dIcon` helper, never inline SVG or `<i class="fa">`:323324 ```gjs325 import dIcon from "discourse/ui-kit/helpers/d-icon";326 // …in <template>: {{dIcon "chevron-left"}}327 ```328329 Use a **real icon name** — icons render from Discourse's registered SVG sprite (a subset of330 Font Awesome), not arbitrary names. Don't guess; if a plugin needs an icon outside the subset,331 register it (`register_svg_icon` in `plugin.rb`).332333- **Icon-only controls need an accessible label.** An icon conveys nothing to a screen reader,334 so a control with only an icon must carry a label: on `<DButton>` use `@title` (an i18n key —335 also a tooltip) or `@ariaLabel`, or `@translatedTitle` for pre-translated text; on raw markup,336 a translated `aria-label`. A button with visible text doesn't need this. (`dIcon` renders the337 glyph `aria-hidden` by default — the accessible name belongs on the control, not the icon.)338- **Screen-reader-only text uses `.sr-only`, not `display: none`.** For text that should exist339 for assistive tech but not show on screen (a label for an icon-only region, a skip target), use340 the `.sr-only` helper — `display: none`/`visibility: hidden` remove it from the accessibility341 tree. See [references/accessibility.md](references/accessibility.md).342- **Announce dynamic content via the `a11y` service — never a hand-rolled `aria-live`.** Content343 that appears without a page navigation (async results, a toast, inline validation) needs344 `this.a11y.announce(message, "polite" | "assertive")` to be read out. Live regions only work345 when **persistent in the DOM before the change** — which is exactly why you route through the346 service rather than adding an `aria-live` element alongside the new content. Details and the347 why: [references/accessibility.md](references/accessibility.md).348- **All display strings are translatable.** Pull copy through `i18n(...)`; never hardcode349 user-facing English. Use placeholders for interpolation — never concatenate translated350 fragments. Write strings in **"Sentence case"**.351- **Semantic, accessible markup.** Reach for the element that describes the content before a352 generic `<div>`/`<span>`:353 - **Landmarks & sectioning** — `<nav>`, `<header>`/`<footer>`, `<main>`, `<aside>`,354 `<section>`/`<article>` expose landmarks and an outline screen-reader users navigate by; a355 wall of `<div>`s gives them nothing to jump between. `<ul>`/`<ol>` + `<li>` for lists,356 `<table>` only for tabular data.357 - **Interactive & form** — real `<button>` for actions (not a clickable `<div>`), `<a>` for358 navigation, `<label>` tied to its input, `<fieldset>`/`<legend>` for groups.359 - Add `alt`/`aria-*` only to fill gaps native semantics can't — don't paper over a wrong360 element with ARIA. And don't add `<section>`/`<nav>` purely as styling hooks where they361 carry no role; a `<div>` is honest there.362 - Prefer existing `<DButton>` and other shared components — they get semantics and a11y right.363- **Buttons: `<button>` for actions, `<a>` for navigation — then one standalone variant.** Choose364 the element by behavior (anything that changes the URL is a link), not looks. A button-looking365 control needs `.btn` **plus exactly one** mutually-exclusive variant (`btn-default`,366 `btn-primary`, `btn-danger`, `btn-flat`/`btn-transparent`); `<DButton>` adds `.btn` for you, so367 pass the variant via `@class`. **Only controls that look *and* function like a standard button368 get these classes** — a `<button>` inside a dropdown, menu, tab, or list row is styled by its369 own component and must not get a `.btn-*` variant. Full guidance:370 [references/css-authoring.md](references/css-authoring.md).371- **Use FormKit for forms — don't roll your own.** Build forms with the `<Form>` component372 (`import Form from "discourse/components/form"`), which yields field/row/submit pieces373 (`<form.Field>`, `<form.Row>`, `<form.Submit>`) and handles layout, validation, state, and the374 label/error/a11y wiring for you. Don't hand-assemble a raw `<form>` with manual `<input>`s and375 bespoke validation. See376 [`docs/developer-guides/docs/03-code-internals/22-form-kit.md`](../../docs/developer-guides/docs/03-code-internals/22-form-kit.md)377 (`frontend/discourse/app/form-kit`).378- **Splat `...attributes` on the component's root element** so a caller can pass a class,379 `data-*`, `aria-*`, or a `--modifier` through. Without it the component is a closed box. The380 root is also where the BEM block class lives: `<div class="user-card" ...attributes>`.381- **Use `dConcatClass` for conditional/computed classes** instead of hand-built strings or382 stacked inline `{{if}}`s (`import dConcatClass from "discourse/ui-kit/helpers/d-concat-class"`).383 It drops falsy values cleanly:384385 ```gjs386 <div class={{dConcatClass "card" (if @selected "is-selected") (if @compact "--compact")}}>387 ```388389- **Know `<PluginOutlet>`, but don't add outlets speculatively.** Outlets are named seams where390 plugins/themes inject content (400+ across the app); you'll work inside them often. Each one is391 a **public API surface and maintenance commitment** — once it exists, extensions depend on its392 name and `@outletArgs`, so it can't be moved freely. Add one only for a concrete need; pass393 data via `lazyHash` (not `hash`) and name it by location (`above-…`, `below-…`). See394 [`14-plugin-outlet-connectors.md`](../../docs/developer-guides/docs/03-code-internals/14-plugin-outlet-connectors.md).395- **Heading levels follow the document outline, not type size.** Never pick a level for its396 default font size — if the right heading looks wrong-sized, style it in CSS397 (`font-size: var(--font-up-1)`). An `<h1>` styled smaller is fine; an `<h3>` chosen because398 you wanted smaller text is not.399- **Avoid "div-itis" — if you can't name what a wrapper does, drop it.** The test for every400 wrapping element: state its layout or semantic job in a few words (its own `max-width`, a401 positioning context, a scroll area, a flex/grid container, a real semantic region). If you402 can't, delete it and let the child stand on its own. A lone `<button>` wrapped in a `<div>`, or403 two or three nested `<div>`s that just pass content straight through, are the usual offenders —404 the markup carries weight it doesn't earn. Pick the right element too (`<span>` inline, `<div>`405 for a block/structural container, a semantic element where one fits). Beyond clutter, a stray406 wrapper between a flex/grid parent and its children **breaks layout** — the items stop being407 direct children, so `gap`/`flex`/`grid-template` no longer reach them. No style, role, or layout408 reason → delete it.409- **Components clean up after themselves — don't render empty containers.** If a container's410 contents are conditional, put the container inside the condition so it isn't emitted when411 empty — an empty-but-present element still counts as a flex/grid item and `gap` slot, leaving412 a phantom gap:413414 ```hbs415 {{! GOOD — nothing emitted when there's nothing to show }}416 {{#if @actions}}417 <div class="card__actions">418 {{#each @actions as |action|}}<DButton @action={{action}} />{{/each}}419 </div>420 {{/if}}421 ```422423 Likewise, **don't put `padding`/`margin`/`gap` on a container that can render empty** — that424 reserves space with no content. And **don't lean on `:empty`** to hide it: Ember leaves425 whitespace/comment nodes (`<!---->`) that make `:empty` fail to match, so it silently won't426 apply. The template conditional is the only reliable guard.427- **No empty backing class** for a template-only component unless explicitly requested.428- Don't add JSDoc to new code; if editing code that already has it, keep it accurate.429430## Where stylesheets live431432Core stylesheets are under `app/assets/stylesheets/`. Place a partial by target, then register433it in the matching `_index.scss` / parent `@import` (partials are underscore-prefixed and434**not** auto-globbed).435436| Path | Applies to |437| --- | --- |438| `common/base/` | **Where new styles go** — one responsive stylesheet for all viewports |439| `common/components/` | Reusable component styles |440| `desktop/` | **Legacy desktop-only** — don't add new styles here |441| `mobile/` | **Legacy mobile-only** — don't add new styles here |442| `*_rtl.scss` | Legacy RTL overrides — new code uses logical properties instead |443444- **Write one responsive stylesheet, not desktop + mobile copies.** Discourse designs445 **mobile-first** and enhances upward (see the philosophy doc,446 [`28-designing-for-devices.md`](../../docs/developer-guides/docs/03-code-internals/28-designing-for-devices.md)):447 new styles live in `common/` and adapt with breakpoints. **Prefer intrinsic layout** (e.g.448 `grid-template-columns: repeat(auto-fill, minmax(14em, 1fr))`) and reach for a breakpoint only449 to *restructure*; use the `lib/viewport` mixins (`viewport.from`/`until`/`between`). The legacy450 device split — the `desktop/`/`mobile/` dirs, the `.mobile-view`/`.desktop-view` classes, and451 `site.mobileView` in JS — is **deprecated**; don't use it. Details, breakpoints, and the452 `capabilities` service: [references/layout-and-responsive.md](references/layout-and-responsive.md).453- **Design to work without hover.** Touch users can't hover, so hover is an *enhancement*, not a454 requirement — nothing essential should be hover-only. When you do add hover styling, scope it455 to `html.discourse-no-touch` (see the layout reference).456- `common/foundation/variables.scss` and `mixins.scss` are injected everywhere — that's where457 layout-width vars and `z()` come from. (Font sizes/line-heights are native custom properties —458 `var(--font-up-2)`, `var(--line-height-medium)`.)459460### Plugins & themes461462- **Plugin** styles live in `plugins/<name>/assets/stylesheets/` and are registered in463 `plugin.rb`: `register_asset "stylesheets/common/my-feature.scss"` (optionally `, :desktop` /464 `, :admin`).465- **Themes/components** ship `common/`/`desktop/`/`mobile/` SCSS compiled with the palette466 injected — the same `var(--…)` and `$…` variables are available, so color, BEM, and native-CSS467 rules apply identically. The same responsive-first rule holds: put new styles in `common/`.468469## Before committing470471Lint every changed file (CSS via stylelint, templates via the JS toolchain):472473```sh474bin/lint --fix path/to/file.scss path/to/file.gjs475bin/lint --fix --recent # all recently changed files476```