name: discourse-writing-html-css description: Write HTML and CSS/SCSS for Discourse core, plugins, themes, and theme components. Use when authoring or modifying templates (.gjs/.hbs), stylesheets (.scss), component markup, or class names. Covers Discourse's BEM-with-standalone-modifiers naming, the CSS custom-property color palette (theming + dark mode), template/HTML conventions, 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
25-css-guidelines-bem.md
(naming) and
27-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/viewportbreakpoint API. - references/css-authoring.md — native-CSS-vs-SASS swaps, local custom properties (incl. theme interaction), shared mixins.
- 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__el2is wrong — useblock__el2); the skeleton uses flat__message,__message-avatar,__message-text. - A modifier is a standalone
.--modifierfor appearance variants (not the verboseblock__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/removerpxso the UI scales with the user's adjustable base font size (pxis fine for hairline borders). Avoid fixed heights/magic dimensions — let content size the box (translated strings and long usernames run longer than English); prefermin-/max-over hardheight/width. Usegapfor flex/grid spacing, not per-child margins. On user-generated text (titles, usernames, URLs), addoverflow-wrap: anywhereso a long unbroken string can't force horizontal scroll.Local custom properties. Hoist a value to a component-scoped
--propertywhen it's reused or feeds acalc()(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— notleft/rightormargin-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 —transformandopacityare GPU-composited; animating layout properties (width,height,top/left,margin) triggers reflow and causes jank. Neveroutline: nonewithout a replacement — use:focus-visibleso 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.
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 rawinnerHTMLfor 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
dIconhelper, 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_iconinplugin.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@translatedTitlefor pre-translated text; on raw markup, a translatedaria-label. A button with visible text doesn't need this. (dIconrenders the glypharia-hiddenby default — the accessible name belongs on the control, not the icon.)Screen-reader-only text uses
.sr-only, notdisplay: 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-onlyhelper —display: none/visibility: hiddenremove it from the accessibility tree. See references/accessibility.md.Announce dynamic content via the
a11yservice — never a hand-rolledaria-live. Content that appears without a page navigation (async results, a toast, inline validation) needsthis.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 anaria-liveelement 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.
- Landmarks & sectioning —
<DButton>style variants — only when it should look like a button. Use the shared variant via@class(btn-default,btn-primary,btn-danger,btn-flat/btn-transparent,btn-small) rather than restyling from scratch. Don't apply them to non-button-looking controls (you'll override more than you saved), and don't fight a<DButton>into a shape it resists — a plain semantic<button class="my-thing">is cleaner there. Variant when button-shaped, naked button when not.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. Seedocs/developer-guides/docs/03-code-internals/21-form-kit.md(frontend/discourse/app/form-kit).Splat
...attributeson the component's root element so a caller can pass a class,data-*,aria-*, or a--modifierthrough. Without it the component is a closed box. The root is also where the BEM block class lives:<div class="user-card" ...attributes>.Use
dConcatClassfor 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 vialazyHash(nothash) and name it by location (above-…,below-…). See13-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" — no wrapper without a job. Pick the right element (
<span>inline,<div>for a block/structural container, a semantic element where one fits), and add one only when it earns its place: its ownmax-width, a positioning context, a scroll area, or a real semantic region. A stray wrapper between a flex/grid parent and its children breaks layout — the items stop being direct children, sogap/flex/grid-templateno 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
gapslot, 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/gapon a container that can render empty — that reserves space with no content. And don't lean on:emptyto hide it: Ember leaves whitespace/comment nodes (<!---->) that make:emptyfail 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,
27-designing-for-devices.md): new styles live incommon/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 thelib/viewportmixins (viewport.from/until/between). The legacy device split — thedesktop//mobile/dirs, the.mobile-view/.desktop-viewclasses, andsite.mobileViewin JS — is deprecated; don't use it. Details, breakpoints, and thecapabilitiesservice: 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.scssandmixins.scssare injected everywhere — that's where layout-width vars andz()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 inplugin.rb:register_asset "stylesheets/common/my-feature.scss"(optionally, :desktop/, :admin). - Themes/components ship
common//desktop//mobile/SCSS compiled with the palette injected — the samevar(--…)and$…variables are available, so color, BEM, and native-CSS rules apply identically. The same responsive-first rule holds: put new styles incommon/.
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