| 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:
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
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.
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.
-
<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. See
docs/developer-guides/docs/03-code-internals/21-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
13-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 own max-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, 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,
27-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