| name | discourse-writing-html-css |
| description | 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. |
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 { … }
.--error & { border-color: var(--danger); }
}
}
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:
.notice { color: #222; background: #fff; border: 1px solid #ddd; }
.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
— and are GPU-composited; animating layout
properties (, , /, ) triggers reflow and causes jank. Never
without a replacement — use so keyboard users get a clear
ring while it stays hidden for mouse clicks.
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.
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: ...;
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 element alongside the new content. Details and the
why: .
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