CONTRIBUTOR-DOCS / Style guide / 2nd-Gen CSS / Styling Anti-Patterns (What to Avoid)
In this doc
- 1. Leaving Visual Styles on
:host - 2. Preserving
--mod-*as an Extra Indirection Layer - 3. Excess Variant Classes in
render() - 4. Increasing Selector Specificity to Force Overrides
- 5. Using
:where()Inside:host()for Custom Property Updates - 6. Exposing Too Many Custom Properties βJust in Caseβ
- 7. Treating Forced-Colors as a Variant
- 8. Leaving Spectrum-Era Classes After Migration
- Before/after refactoring examples
- 9. Nesting compound pseudo-classes on
:host()via CSS nesting - 10. Size-Specific Custom Properties
- 11. Suppressing focus outlines with
:focus { outline: none } - Final Reminder
This appendix lists common mistakes encountered when adopting the 2nd-gen SWC styling model, why they happen, and what to do instead.
Each anti-pattern is grounded in real Spectrum source patterns. Badge and Status Light are reference implementations for correct patterns.
π Reference implementations: Badge Β· Status Light Β· Reference Migration: Badge
:host {
padding: 8px;
background-color: var(--spectrum-badge-background-color-default);
}- Spectrum CSS often treated the custom element root as the primary styling surface
- Incremental migrations make it tempting to keep existing rules in place
:hostis part of the public styling API- Visual styles here are harder to override predictably
- This breaks the SWC model where
:hostdefines layout participation only
:host {
display: inline-flex;
}
.swc-Badge {
background: var(
--swc-badge-background-color,
token("neutral-subdued-background-color-default")
);
}π Badge reference:
See the migrated Badge where :host is limited to layout (display, place-self, vertical-align) and all visual styling lives on .swc-Badge.
π See: Component CSS Style Guide β Rule order
Three categories of styles may legitimately live on :host, each for a distinct reason:
- UA style resets β the browser applies default styles directly to the host element (for example, the native popover stylesheet). Those cannot be overridden from an inner class and must be reset on
:host. - Entry/exit transitions β
opacity,transition-*, andtransition-behavior: allow-discretemust be on:hostwhen the host element is itself the transition target (for instance, when@starting-styleoroverlayapplies to the host rather than a descendant). - Positioning surface β
position: absolute,inset: auto, and dimension constraints belong on:hostwhen an external controller (such as a placement controller) writes coordinates directly to the host element.
π See: Component CSS Style Guide β When to use :host
This anti-pattern reflects one of the most common and subtle migration mistakes.
Preserving Spectrum-era --mod-* fallback chains, or introducing an SWC equivalent:
min-block-size: var(--mod-badge-height, var(--swc-badge-height));
border-radius: var(--mod-badge-corner-radius, var(--swc-badge-corner-radius));
background: var(--mod-badge-background-color-default, var(--swc-badge-background-color-default));or:
min-block-size: var(--swc-mod-badge-height, token("component-height-100"));--mod-*functioned as a lightweight override hook in Spectrum CSS- It allowed customization without modifying base rules
- Preserving the pattern can feel safer during migration
--mod-*adds an unnecessary layer of indirection- Long fallback chains are harder to reason about and override
- It obscures which values are intentionally exposed by the component
- It conflicts with SWCβs model of explicit component-level customization
- Remove
--mod-*entirely - Collapse the fallback chain into a single component custom property
- Decide whether the property should be:
- exposed (
--swc-*) - or internal (
--_swc-*)
- exposed (
- Reference design tokens directly via
token()
.swc-Badge {
min-block-size: var(--swc-badge-height, token("component-height-100"));
border-radius: var(
--swc-badge-corner-radius,
token("corner-radius-medium-size-medium")
);
background: var(
--swc-badge-background-color,
token("neutral-subdued-background-color-default")
);
}π Badge reference:
See the Badge migration where all --mod-* β spectrum β property chains are collapsed into intentional --swc-badge-* properties.
π See: Custom Properties Style Guide β Component custom property exposure
This is an anti-pattern when the class is not being used in the actual component stylesheet.
classMap({
[`spectrum-Badge--size${this.size?.toUpperCase()}`]:
typeof this.size !== 'undefined',
})- Legacy Spectrum class-based patterns
- Uncertainty about expressing variants via attributes
- Duplicates logic already expressed by attributes
- Extranneous when class not actually being used as a style hook
:host([size="l"]) {
--swc-badge-height: token("component-height-200");
}π Badge reference:
Badge size, variant, subtle, and outline states are all expressed via :host() selectors and custom property updates.
π See: Component CSS Style Guide β Variant implementation patterns
/* Multiple compounded classes = (0,3,0) */
.swc-Badge.swc-Badge--large.swc-Badge--primary {
padding: 16px;
}
/* Or stacking to "win" a conflict */
.swc-StatusLight.swc-StatusLight--yellow.swc-StatusLight--sizeL {
font-size: 20px;
}- Conflicting migrated rules
- Attempting to preserve visual parity through selector escalation
- Copying patterns from other codebases that use high specificity
- Breaks the
(0,1,0)specificity target - Makes overrides brittle (e.g. disabled state needs even higher specificity)
- Hides ordering or layering issues that should be fixed instead
- Fix rule order first
- Use
:where()for compounding selectors - Introduce cascade layers only when necessary
/* Before: (0,2,0) */
.swc-Divider--staticWhite.swc-Divider--sizeL {
--swc-divider-background-color: token("transparent-white-800");
}
/* After: (0,1,0) - rule order determines winner */
.swc-Divider--staticWhite:where(.swc-Divider--sizeL) {
--swc-divider-background-color: token("transparent-white-800");
}π Badge reference:
badge.css uses .swc-Badge--subtle:where(.swc-Badge--gray) for compounded variants. Divider uses the same pattern for static color + size.
π See: Component CSS Style Guide β Managing Specificity
:host:where([size="l"][variant="primary"]) {
--swc-badge-height: 40px;
}- Over-application of
:where()as a universal fix - Assuming specificity controls custom property precedence
- Custom properties resolve via inheritance, not specificity
- This obscures intent and adds complexity
:host([size="l"][variant="primary"]) {
--swc-badge-height: token("component-height-200");
}π Badge reference:
Badge safely compounds attributes within :host() when updating custom properties only.
π See: Component CSS Style Guide β Shadow DOM specificity and custom property inheritance
--swc-badge-border-radius
--swc-badge-gap
--swc-badge-icon-offset- Desire to future-proof
- Legacy expectations of deep customization
- Bloats the public API
- Makes refactors harder
- Encourages unsupported overrides
- Expose only what the component itself needs based on its own variant, state, or size requirements
- Keep mechanical and derived values private
- Exception: expose properties required for nested component relationships or shared utility styling
π Badge reference:
Badge exposes a minimal, intentional surface and uses _swc-* properties for derived calculations.
π See: Custom Properties Style Guide β Private properties
.swc-Badge {
border-color:
var(--high-contrast-badge-border-color, var(--swc-badge-border-color, token("badge-border-color")));
}- Treating accessibility as a customization hook
- Forced-colors must override consumer styles
- Accessibility takes precedence over customization
- re-use existing component custom property to apply overrides
- properly order forced-colors at the end of the stylesheet
- attach to internal class-based selectors
@media (forced-colors: active) {
.swc-Badge {
--swc-badge-border-color: CanvasText;
}
}π Status Light reference:
status-light.css overrides --swc-status-light-content-color and adds a border to the dot pseudo-element so it stays visible in high-contrast mode.
π See: Component CSS Style Guide β Forced colors requirements
<div class="swc-Badge spectrum-Badge spectrum-Badge--sizeL">- Incremental migration
- Hesitation to remove legacy code
- Leaves dead code in
render() - Obscures whether migration is complete
- Encourages regression
- Remove Spectrum-era classes once CSS migration is complete
- Treat this as a validation step, not cleanup
π Badge reference:
After migration, Badge relies solely on .swc-Badge and attributes.
π See: Spectrum CSS to SWC Migration β Validation step: removing legacy classes
| Before | After |
|---|---|
:host { padding: 8px; background: blue; } |
:host { display: inline-block; } + .swc-Badge { padding: ...; background: ...; } |
| Before | After |
|---|---|
.swc-Badge--subtle.swc-Badge--gray { } |
.swc-Badge--subtle:where(.swc-Badge--gray) { } |
| Before | After |
|---|---|
class="swc-Badge spectrum-Badge--sizeL" |
class="swc-Badge" + :host([size="l"]) { --swc-badge-height: ...; } |
| Before | After |
|---|---|
var(--mod-badge-height, var(--spectrum-badge-height)) |
var(--swc-badge-height, token("component-height-100")) |
/* Intends to target the host in RTL when placement="start" is open */
:host([placement="start"]:popover-open) {
transform: translateX(calc(-1 * var(--_swc-component-animation-distance)));
&:dir(rtl) {
transform: translateX(var(--_swc-component-animation-distance));
}
}CSS nesting with & replaces & with the parent selector. Inside a :host([...]) rule, &:dir(rtl) expands to :host([...]):dir(rtl) β a pseudo-class chained after the :host() function. This looks syntactically correct, but browsers do not support compound selectors appended outside of the :host() argument.
- The rule silently fails: the
:dir()override never applies - No lint or parse error is produced, making it hard to detect
- Properties meant for RTL layout apply in all directions
Move all conditions inside the :host() argument as a compound selector:
:host([placement="start"]:popover-open) {
transform: translateX(calc(-1 * var(--_swc-component-animation-distance)));
}
:host(:dir(rtl)[placement="start"]:popover-open) {
transform: translateX(var(--_swc-component-animation-distance));
}This restriction only applies when :host() is the outermost element being targeted. When nesting targets a descendant of the host, expanding &:dir(rtl) applies :dir() to the inner element β which is valid:
/* β
Fine: :dir(rtl) targets .swc-Component-tip, not :host() */
:host([placement="end"]) .swc-Component-tip {
transform: rotate(45deg);
&:dir(rtl) {
transform: rotate(-135deg);
}
}:dir() is the most common pseudo-class where this issue surfaces during migrations because RTL overrides are nearly always added after a component's base styles are written. When adding :dir() to any :host-level rule during migration, always write it as a separate :host(:dir(rtl)[...]) rule rather than a nested &:dir(rtl).
:host([size="compact"]) {
--_swc-accordion-compact-padding-top: token("spacing-100");
}- Attempting to keep size-specific values "private" while still referencing them in variant rules
- Mapping one custom property per size variant for clarity
- A custom property defined on
:host([size="compact"])is part of the component's external style surface β the--_swc-*prefix does not make it inaccessible from outside the shadow root - Every size requires its own named property, bloating the API surface
- Consumers cannot override a single "padding-top" concept; they must know and target every size-specific property name
Expose a single property on the base and override it per size selector:
.swc-Accordion {
padding-block-start: var(--swc-accordion-padding-top, token("spacing-200"));
}
:host([size="compact"]) {
--swc-accordion-padding-top: token("spacing-100");
}
:host([size="spacious"]) {
--swc-accordion-padding-top: token("spacing-300");
}Consumers targeting a specific size can still override via attribute selectors on the host:
swc-accordion[size="compact"] {
--swc-accordion-padding-top: var(--my-compact-spacing);
}π See: Custom Properties Style Guide β Component custom property exposure
.swc-Component-header:focus {
outline: none;
}- Carried over from 1st-gen Spectrum CSS, which managed focus rings through its own system
- Removes the focus indicator for keyboard users β an accessibility violation (WCAG 2.4.7)
- The component already has a
:focus-visiblerule; the:focussuppression just interferes with it
- Remove the rule entirely.
:focus-visiblealready handles when to show the ring; no explicit suppression is needed.
If you find yourself:
- adding more classes,
- increasing selector specificity,
- or preserving Spectrum-era indirectionβ
pause and re-evaluate using the SWC styling model.
The Badge migration demonstrates the intended end state:
explicit customization, reduced indirection, and CSS that works with layout models instead of against them.