Skip to main content

Patterns: Styling

SCSS architecture, design tokens, the cssVars() JS-to-CSS bridge, BEM prefix rules, and the conditional-className / inline-style policy. Load via the patterns-styling context group.

Architecture

All styles are global SCSS (no CSS Modules, no styled-components, no utility class system). Files are bundled by Webpack via ts-loader + scss-loader into a single CSS output.

src/styles/
app.scss # Single entry — only @import statements
customVariables.scss # CSS custom properties + SCSS aliases
reset.scss # Scoped CSS reset under .hbf-bot-div
coreWidget.scss # botframework-webchat overrides under .hbf-bot-div
bubbleWidget.scss # Floating-bubble layout
embedWidget.scss # Embedded inline layout
csatPopup.scss # CSAT overlay
customNotifications.scss # Toast notifications
customButtonsContainer.scss # Send-box custom button row
adaptiveCard.scss # Adaptive Card carousel overrides
popUpBubbleWidget.scss # Proactive prompt bubble
preChatSteps.scss # Pre-chat form
botActivityStatusWithReactions.scss
emojiPicker.scss
homeActionButton.scss
customActionButton.scss
localeDropdown.scss
endLiveChatSendboxButton.scss
microphoneButton.scss
listeningOverlay.scss
fonts.scss # @font-face declarations
animations.scss # @keyframes only

app.scss is the only entry point

src/styles/app.scss contains only @import statements. There are no rule blocks in it. Every SCSS file must be added to app.scss to be included in the bundle.

// src/styles/app.scss (verbatim)
@import "customVariables";
@import "reset";
@import "coreWidget";

@import "animations";
@import "fonts";

@import "customNotifications";
@import "customButtonsContainer";
@import "adaptiveCard";
@import "bubbleWidget";
@import "csatPopup";
@import "embedWidget";
@import "popUpBubbleWidget";
@import "preChatSteps";
@import "botActivityStatusWithReactions";
@import "emojiPicker";
@import "homeActionButton";
@import "customActionButton";
@import "localeDropdown";
@import "endLiveChatSendboxButton";
@import "microphoneButton";
@import "listeningOverlay";

No per-component import "./foo.scss" from .tsx files. The only two SCSS imports in source code are:

  • src/index.ts:2import "./styles/app.scss" (side-effect import for Webpack)
  • src/app.ts:7import appStyles from "./styles/app.scss" (named import, used to inject a <style> tag into Shadow DOM via style.textContent = ${appStyles})

To add a SCSS file: create src/styles/<name>.scss, then add @import "<name>"; to src/styles/app.scss. Do not import the SCSS file directly into a .tsx/.ts component file.

File scope

One file per UI domain. Do not split per component. The unit is "thing the user perceives" (CSAT popup, locale dropdown, send-box mic button), not "React component file".

Design Tokens (customVariables.scss)

The bridge between SCSS and runtime JavaScript is two layers in one file:

Layer 1 — CSS custom properties on :root, :host

:root, :host {
--widgetColor: red;
--primaryFontFamily: "Segoe UI", sans-serif;
--headerBgColor: red;
--widgetHeight: 650px;
--widgetWidth: 400px;
--actionButtonBorderRadius: 6px;
--accentColor: red;
--botBubbleBackgroundColor: #cccccc;
--csatFontSize: 14px;
--carouselCardWidth: 250px;
/* ... */
}

The fallback values (red, #cccccc, etc.) are placeholders — at runtime they are overwritten by cssVars() (see below) using values resolved from botPreferences. The :host selector covers the Shadow DOM case where the widget is mounted under a ShadowRoot.

Layer 2 — SCSS $var: var(--var) aliases

$widgetColor: var(--widgetColor);
$headerFontFamily: var(--headerFontFamily);
$headerBgColor: var(--headerBgColor);
$widgetHeight: var(--widgetHeight);
$accentColor: var(--accentColor);
$csatMainColor: var(--csatMainColor);
/* ~50 aliases total */

These let SCSS files reference tokens with normal $var syntax while the actual value is resolved via CSS custom property at runtime. Always use the $var form in component SCSS files; never reference var(--foo) directly outside customVariables.scss.

Adding a new token

  1. Add --myNewToken: <fallback>; under :root, :host in customVariables.scss.
  2. Add $myNewToken: var(--myNewToken); to the alias block below.
  3. If the value is dynamic per deployment, add it to the cssVars({ variables: { ... } }) call in src/app.ts (both BUBBLE and EMBEDDED branches if applicable).
  4. Use $myNewToken in component SCSS files.

The cssVars() Ponyfill Bridge

css-vars-ponyfill is the single mechanism that pushes runtime JS values into CSS custom properties.

It is called twice in src/app.ts:

  • Line ~86 (BUBBLE branch) — passes ~30 --* variables derived from botPreferences.widgetStyleSet and botPreferences.styleSetOptions
  • Line ~170 (EMBEDDED branch) — passes a smaller subset for embedded mode
cssVars({
rootElement: RootElementsManager.getWebChatContainer(botPreferences.botDivId) ?? document,
variables: {
"--widgetColor": botPreferences.widgetStyleSet.widgetColor,
"--borderRadius": botPreferences.widgetStyleSet.borderRadius,
// ... many more
},
});

rootElement scopes the polyfill to the widget's container (Shadow DOM if present), avoiding global pollution when multiple widgets share a page.

There are no element.style.setProperty(...) calls anywhere in the codebase. All runtime theming flows through cssVars(). Do not introduce direct property mutation as a shortcut.

BEM and Class Naming

Class-name selectors split across four namespaces in roughly these proportions across src/styles/*.scss:

PrefixShareOwnerExamples
webchat__*~36%botframework-webchat internals (we override).webchat__bubble, .webchat__send-box__main, .webchat__suggested-actions, .webchat__csat__container
Flat / unprefixed~37%legacy Helvia HTML (no prefix).chat-header, .btn-sonar, .bubble-widget-chat-container, .spinner
ac-*~14%adaptivecards library overrides.ac-container, .ac-dateInput, .ac-multichoiceInput
hbf-* / hbf__*~12%Helvia-internal new code.hbf-bot-div, .hbf__tooltip, .hbf-listening-overlay, .hbf__send-box__button

Rules going forward

  1. webchat__* — use only when overriding a class that botframework-webchat already renders. The library produces these class names; the SCSS rules layer customizations on top. Do not invent new webchat__ classes for Helvia-only HTML.
  2. hbf- / hbf__ — use for any new Helvia-internal HTML you own (widget classes, manager-rendered DOM, custom React components). Use BEM nesting (hbf-block__element--modifier).
  3. Flat names (.chat-header, .btn-chat, .bubble-widget-chat-container) — legacy. Do not introduce new flat names. Touching one in a fix is acceptable; rewriting them all is not in scope.
  4. ac-* — never write these by hand; they come from adaptivecards. Override under a .hbf-bot-div ancestor selector.

Root scope

Every Helvia-rendered widget is wrapped in <div class="hbf-bot-div ...">. Component SCSS files generally scope their rules under .hbf-bot-div to avoid leaking outside the widget when the host page does not mount the widget in Shadow DOM.

.hbf-bot-div {
.my-new-block {
&__element { ... }
&--modifier { ... }
}
}

Conditional className

Use a ternary string interpolation in className. The classnames library is not a dependency and must not be added.

// ✅ correct
className={`btn ${isActive ? 'btn--active' : ''}`}

// ✅ multiple conditions
className={`hbf-block ${isActive ? 'hbf-block--active' : ''} ${isCollapsed ? 'hbf-block--collapsed' : ''}`}

// ✅ from a constant
className={isActive ? `${BASE_CLASSNAME} ${BASE_CLASSNAME}--active` : BASE_CLASSNAME}

// ❌ do NOT add the classnames library
import classNames from "classnames"; // not a dependency

The ternary form produces an empty string when the condition is false; that is acceptable. The trailing space and empty-class artifacts ("btn ") are tolerated.

Inline style={{}}

The codebase has very few inline styles overall (5 occurrences across all .tsx files in src/components/). The policy is:

Acceptable: runtime-dynamic values

Values computed from props, theme tokens, or botPreferences go in style={{}} because they cannot be expressed in static CSS.

// CustomNotificationMessage.tsx — color from runtime styleOptions
style={{
color: styleOptions.toastInfoBackgroundColor,
backgroundColor: styleOptions.toastInfoColor,
}}

Tolerated: static magic-number constraints

Static numeric values for one-off layout (margins, max-widths, heights) are tolerated. The current inventory is two sites, both marginBottom: "16px":

  • src/components/CustomCSATPopupMessage.tsx:259
  • src/components/RatingForm/CustomRatingFormPopupMessage.tsx:395

Do not extend this pattern. Prefer a SCSS class. The two existing sites are not being migrated; they are flagged in anti-patterns.md for visibility but not actively tracked.

Forbidden: hardcoded colors

Hex/rgba/named colors in style={{}}. Always go through a token (customVariables.scss$varcssVars() injection) or runtime styleOptions from botPreferences.

Decision tree

  1. Is the value computed at runtime from props / botPreferences / styleOptions? → style={{}} (acceptable).
  2. Is it a hardcoded color? → No. Use a token.
  3. Is it a static one-off pixel constraint and the touched site already does this? → Tolerated, do not extend.
  4. Otherwise → SCSS class in the appropriate src/styles/<name>.scss file.

Translations and Locale

Each new translation key must be added to every JSON under src/assets/translations/ (28 locales). LocaleManager.translate() in src/locale/LocaleManager.ts throws an Error at runtime when a key is missing from the active locale's JSON; the fallback only activates if the entire JSON file fails to load, not for an individual missing key.

To add a locale: create <locale>.json under src/assets/translations/ with all existing keys, then add the locale code to SupportedLocalesArr in src/locale/LocaleManager.ts.