discourse-writing-html-css

star 47.3k

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.

discourse By discourse schedule Updated 6/1/2026

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:

  1. Name classes with BEM so themes can target and override cleanly.
  2. 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:

  • Positionblock-rightblock__sidebar, block__actions.
  • Colorwarning-red / text-blueblock--warning, block__link.
  • Sizebox-300px, text-largeblock__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 helpersz("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 propertiestransform 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   # all recently changed files
Install via CLI
npx skills add https://github.com/discourse/discourse --skill discourse-writing-html-css
Repository Details
star Stars 47,276
call_split Forks 8,936
navigation Branch main
article Path SKILL.md
More from Creator