Patterns: Styling
SCSS architecture, design tokens, the
cssVars()JS-to-CSS bridge, BEM prefix rules, and the conditional-className / inline-style policy. Load via thepatterns-stylingcontext 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:2—import "./styles/app.scss"(side-effect import for Webpack)src/app.ts:7—import appStyles from "./styles/app.scss"(named import, used to inject a<style>tag into Shadow DOM viastyle.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
- Add
--myNewToken: <fallback>;under:root, :hostincustomVariables.scss. - Add
$myNewToken: var(--myNewToken);to the alias block below. - If the value is dynamic per deployment, add it to the
cssVars({ variables: { ... } })call insrc/app.ts(both BUBBLE and EMBEDDED branches if applicable). - Use
$myNewTokenin 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 frombotPreferences.widgetStyleSetandbotPreferences.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:
| Prefix | Share | Owner | Examples |
|---|---|---|---|
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
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 newwebchat__classes for Helvia-only HTML.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).- 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. ac-*— never write these by hand; they come from adaptivecards. Override under a.hbf-bot-divancestor 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:259src/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 → $var → cssVars() injection) or runtime styleOptions from botPreferences.
Decision tree
- Is the value computed at runtime from props /
botPreferences/styleOptions? →style={{}}(acceptable). - Is it a hardcoded color? → No. Use a token.
- Is it a static one-off pixel constraint and the touched site already does this? → Tolerated, do not extend.
- Otherwise → SCSS class in the appropriate
src/styles/<name>.scssfile.
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.