- Working examples: badge.css, status-light.css
- Main rules: 01_component-css
- Custom property decisions: 02_custom-properties
- PR self-check: 03_component-css-pr-checklist
:host {
display: inline-block;
}
* {
box-sizing: border-box;
}
.swc-Badge {
--_swc-badge-border-width: token('border-width-200');
min-block-size: var(--swc-badge-height, token('component-height-100'));
background: var(
--swc-badge-background-color,
token('accent-background-color-default')
);
}
:host([size='s']) {
--swc-badge-height: token('component-height-75');
}
:host([variant='positive']) {
--swc-badge-background-color: token('positive-background-color-default');
}
.swc-Badge--magenta:where(.swc-Badge--subtle) {
--swc-badge-background-color: token('magenta-background-color-default');
}/*
- visual styles are incorrectly on :host
- legacy --mod-* indirection is preserved
- hardcoded values instead of token()
- selector specificity is too high
*/
:host {
padding: 8px;
background: var(--mod-badge-background, var(--swc-badge-background-color));
}
.swc-Badge.swc-Badge--large.swc-Badge--primary {
padding: 16px;
}Put layout-participation styles on :host (display, inline-size, min-*/max-*, position, custom property definitions). Put visual styles on .swc-ComponentName or an internal part.
Exception: three categories of styles may legitimately live on :host, each for a distinct reason: UA resets when the browser applies default styles directly to the host element (for example, the native popover stylesheet); transition properties (opacity, transition-*, transition-behavior: allow-discrete) when the host is itself the transition target (for example, when @starting-style applies to the host); and positioning surface (position: absolute, inset: auto, dimension constraints) when an external controller such as a placement controller writes coordinates directly to the host element.
Two non-obvious cases to flag explicitly:
paddingon:host— feels like layout but is visual spacing; move it to the internal class.cursor: pointer— do not set it anywhere; the project relies on browser defaults.
Also check that display: flex or display: grid on :host is actually laying out direct children of :host, not internal children already wrapped inside a container element. Flex/grid properties (flex: 1 1 auto, align-self) only activate when their immediate parent is the flex/grid container — if the element is inside a wrapper div, the flex context must be on that wrapper, not on :host.
:host:has() is unreliable across browsers. Safari and Firefox do not consistently support :has() relative to a shadow host boundary. Move all :has() selectors to the internal wrapper: .swc-Component:has(...) instead of :host:has(...). Custom properties cascade identically either way. See 01_component-css#state-implementation-patterns.
→ See 01_component-css#when-to-use-host
Follow the prescribed stylesheet order to manage specificity and reduce selector conflicts. → See 01_component-css#rule-order
Use token() for design token values. Use --swc-* only for intentionally exposed override points, and --_swc-* for internal/private properties. Do not keep old --mod-* chains.
Key rules:
--_swc-*on:host/:host()is not private. Properties on the host element are part of the external style surface — consumers can set them regardless of the prefix. Declare truly private properties on the internal wrapper (.swc-ComponentName), not on:host.- Expose only when the component itself overrides the property based on its own variant, state, or size needs. Do not expose for consumer convenience. Exceptions: nested component relationships and shared utility properties.
- No size-specific custom properties. When a property changes per size, expose a single property (e.g.
--swc-button-padding-vertical) and override it per size selector (:host([size="s"])). Do not create size-specific properties — they become publicly addressable API. - Prefer variant overrides over per-variant redefinition. For component API attributes (size, variant, fill-style), set a CSS property once on the base using a custom property and override it per
:host([variant]). Never create variant-specific custom property names. - Native browser states on internal elements need state-specific custom properties. For properties that change across
:hover,:focus-visible,:activeon an internal wrapper (e.g..swc-Button), expose one property per state (--swc-button-background-color-default,-hover,-focus,-down). Override the full set from:host([variant])and:host([static-color]). This is the mechanism that makes static-color compound overrides possible. Exception: properties that never vary by variant (e.g.outlineon:focus-visible) — define those directly on the state selector. - Use full property names in custom property names:
paddingnotpad,backgroundnotbg,colornotclr.
Every exposed --swc-* property must be documented with a @cssprop JSDoc tag on the primary component class export (the SWC layer class, not the core base). Storybook picks these up automatically and surfaces them in the API docs panel.
/**
* @cssprop --swc-button-height - Block size of the button. Defaults to the medium component height token.
* @cssprop --swc-button-border-radius - Corner radius. Defaults to half the component height (pill shape).
*/
export class Button extends ButtonBase { … }→ See 02_custom-properties
Use :host([size="..."]) and :host([variant="..."]) for consumer-settable attributes that should expose a customization surface. Use .swc-ComponentName--modifier for:
- Implementation details that should not expose overrides (non-semantic color variants, static color, geometric modifiers)
- Derived states — visual modes computed from slot content or internal logic, not set by consumers (e.g. icon-only layout derived from
hasIcon && !hasLabel). These must never appear as a host attribute; apply them viaclassMapin the template.
// ✅ Derived state as a class modifier — not a consumer attribute
class=${classMap({ 'swc-Button': true, 'swc-Button--iconOnly': this.hasIcon && !this.hasLabel })}→ See 01_component-css#when-to-use-classes-vs-attributes
When migrating block padding, use component-padding-vertical-{scale} (S2). Do not carry forward component-top-to-text-{scale} or component-bottom-to-text-{scale} from S1/Spectrum CSS — those were offset hacks to compensate for glyph positioning in the old typeface. Adobe Clean VF corrected the glyph position, so the offsets no longer produce visually centered text in S2 components.
→ See 01_component-css#vertical-spacing-tokens
Keep selector specificity at or below (0,1,0). If you need a compounded selector, use :where() on the extra class instead of stacking specificity. Don't use higher-specificity selectors to "win."
→ See 01_component-css#managing-specificity and 05_anti-patterns
Only add @media (forced-colors: active) if browser defaults are not conveying correct semantic intent, and always put it at the end of the component stylesheet.
Semantic HTML elements (<button>, <input>, <a>) get correct forced-colors treatment automatically — ButtonText, focus Highlight, disabled GrayText — without any CSS override. Only non-semantic elements (a decorative <div> or a <span> using background-color as a visual indicator) need explicit overrides. Do not carry over forced-colors rules from 1st-gen Spectrum CSS without first verifying the 2nd-gen component uses non-semantic markup that requires them.
→ See 01_component-css#forced-colors-requirements
Prefer native CSS pseudo-classes over attribute selectors when one exists for the same state (:host(:popover-open) not :host([open]); :host(:disabled) not :host([disabled])). Use attribute selectors for custom attributes and ARIA states with no native pseudo-class.
→ See 01_component-css#state-implementation-patterns
When a sub-element must always match a variant-driven property on the parent, use inherit rather than repeating the var() reference in each variant rule.
→ See 01_component-css#variant-implementation-patterns
CSS nesting inside a :host([...]) rule — e.g. &:dir(rtl) — expands to :host([...]):dir(rtl), which chains the pseudo-class outside the :host() argument. Browsers do not support this; the rule silently fails with no parse error.
/* ❌ Silent failure: :dir(rtl) is outside :host() */
:host([placement='start']:popover-open) {
&:dir(rtl) {
transform: translateX(1rem);
}
}
/* ✅ All conditions inside the :host() argument */
:host(:dir(rtl)[placement='start']:popover-open) {
transform: translateX(1rem);
}Exception: when the parent selector targets a descendant (e.g. :host([...]) .swc-Child), nesting &:dir(rtl) correctly applies :dir() to the inner element — that is valid and fine.
Migration note: :dir() is a common place this surfaces. Whenever you add :dir() to a :host-level rule, write a separate :host(:dir(rtl)[...]) rule instead of nesting.
→ See 05_anti-patterns#9