Appearance
Extension Patterns
CSS-first patterns for building custom elements that ship sensible defaults and are cleanly extensible by consumers. No naming conventions bolted on top of CSS, no forced subclassing, no JS-in-CSS.
Foundations
Light DOM custom elements
Render into the element's own children rather than a shadow root, so the normal CSS cascade reaches in and consumers don't need a parallel styling API.
The template lives in HTML as a <template> element, the initialization logic lives next to it in a <script>, both keyed by a matching name attribute. The script grabs its template by selector and clones it into the element. No template strings, no JSX, no build step — just HTML, JS, and a naming convention.
html
<template name="my-card">
<header part="header"><slot name="header"></slot></header>
<div part="body"><slot></slot></div>
</template>
<script name="my-card">
class MyCard extends HTMLElement {
#initialized = false;
connectedCallback() {
if (this.#initialized) return;
this.#initialized = true;
const template = document.querySelector('template[name="my-card"]');
this.append(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>Native CSS nesting + the element tag as scope
The custom element tag is a natural scope. Nesting keeps related rules together without inventing class names.
css
my-card {
display: block;
background: white;
padding: 1rem;
> [part="header"] {
font-weight: 600;
border-bottom: 1px solid #eee;
}
}Cascade layers for predictable override
Wrap component defaults in @layer components so consumer styles win without specificity tricks.
css
@layer reset, base, tokens, components, utilities, overrides;
@layer components {
my-card {
background: white;
}
}
/* Consumer, unlayered — wins automatically */
my-card {
background: #faf7f2;
}The de facto layer order convention, popularized by Miriam Suzanne (designer of cascade layers):
css
@layer reset, base, tokens, components, utilities, overrides;Low priority on the left, high on the right. Unlayered styles beat all layered styles, so consumer page CSS naturally wins.
The override surfaces
1. Targeting elements without knowing the structure — part attributes
::part() is a Shadow DOM feature, but the part attribute itself is just an attribute. Use it in light DOM as a stable, named targeting API. The rendered tag names and internal classes become implementation detail you can change freely.
Component:
html
<template name="my-card">
<header part="header"><slot name="header"></slot></header>
<div part="body"><slot></slot></div>
<footer part="footer"></footer>
</template>
<script name="my-card">
class MyCard extends HTMLElement {
#initialized = false;
connectedCallback() {
if (this.#initialized) return;
this.#initialized = true;
const template = document.querySelector('template[name="my-card"]');
this.append(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>Consumer:
css
my-card [part="header"] {
font-family: "Söhne";
}
my-card [part="body"] {
padding-inline: 2rem;
}2. Setting individual values — CSS custom properties
Define properties at the host with sensible defaults, use them internally, expose them as the per-knob API.
Component:
css
@layer components {
my-card {
--my-card-bg: white;
--my-card-padding: 1rem;
--my-card-radius: 8px;
--my-card-border: 1px solid #e5e5e5;
background: var(--my-card-bg);
padding: var(--my-card-padding);
border-radius: var(--my-card-radius);
border: var(--my-card-border);
}
}Consumer:
css
my-card {
--my-card-bg: #faf7f2;
--my-card-radius: 12px;
}
.featured-card {
--my-card-border: 2px solid gold;
}Conventions:
- Namespace property names with the tag:
--my-card-bg, not--bg. Custom properties inherit and unprefixed names collide across components. - Layer the defaults: define design tokens at
:root(--color-surface,--space-md), have components default to tokens (--my-card-bg: var(--color-surface)), let instances override either layer. - Use
@propertywhen you need type-checking, controlled inheritance, or animation:
css
@property --my-card-radius {
syntax: "<length>";
inherits: false;
initial-value: 8px;
}3. Turning off all visual styles — structure/visual sub-layers
The custom-element equivalent of appearance: none. Split component CSS into two nested cascade layers — structure always applies, visual can be stripped surgically by the consumer using revert-layer.
Component author declares two sub-layers and splits the rules:
css
@layer components.structure, components.visual;
@layer components.structure {
my-card {
display: block;
}
my-card[hidden] {
display: none;
}
}
@layer components.visual {
my-card {
background: var(--my-card-bg);
border: var(--my-card-border);
border-radius: var(--my-card-radius);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
> [part="header"] {
font-weight: 600;
border-bottom: 1px solid #eee;
}
}
}The discipline is the split:
- Structure (always applied):
display, layout primitives,[hidden], focus management — things the component needs to work. - Visual (revertable): colors, borders, shadows, typography, decorative spacing.
How revert-layer works
all: revert-layer reverts every property to whatever the next-lower layer set. The layer it reverts past is determined by where the rule itself is written, not by anything in the declaration.
Three ways to use it, depending on intent:
Strip everything any layer set — write the override unlayered. Sits above all layers, so it reverts past all of them back to user agent defaults:
css
my-card {
all: revert-layer;
}Strip just the visual layer, keep structure — write the override inside the visual layer. Reverts past components.visual but components.structure still applies, so the card keeps display: block but loses background, border, etc.:
css
@layer components.visual {
my-card {
all: revert-layer;
}
}Note: there's no functional form of revert-layer that lets you name a specific layer from outside it. The targeting is always done by writing the rule inside the layer you want to revert past. A revert-layer() function is an open proposal in the CSS Working Group (csswg-drafts #11773, opened February 2025), but it's not shipped in any browser as of May 2026 — and even the proposed form takes a property name rather than a layer name.
Stripping one instance vs all instances
The selector picks which instances. The @layer wrapper picks what gets stripped. Two independent dials.
One instance, strip everything visual:
css
@layer components.visual {
#checkout-card {
all: revert-layer;
}
}All instances, strip everything visual:
css
@layer components.visual {
my-card {
all: revert-layer;
}
}A scoped region, strip everything visual:
css
@layer components.visual {
.bare-zone my-card {
all: revert-layer;
}
}The catch with all
all: revert-layer reverts every property, including custom properties you set on the host:
css
my-card {
--my-card-bg: #faf7f2; /* this gets reverted too */
all: revert-layer;
}Order doesn't save you — all resets everything regardless of where it appears. If you want to keep specific properties, set them after all:
css
my-card {
all: revert-layer;
--my-card-bg: #faf7f2; /* survives because it's set after `all` */
}Or revert only what you mean to revert:
css
my-card {
background: revert-layer;
border: revert-layer;
border-radius: revert-layer;
box-shadow: revert-layer;
}For a true "appearance: none" use case, all: revert-layer is the right hammer — you usually do want to wipe everything visual.
Behavior extension without forced subclassing
Subclass only when the subclass genuinely is-a the parent and you need polymorphic substitutability. Don't subclass to "add a feature" or "tweak behavior" — that's what these seams are for.
Events as the outbound API
Dispatch CustomEvents for everything interesting. Consumers attach behavior from outside without touching your class.
js
class MyCard extends HTMLElement {
expand() {
this.setAttribute('expanded', '');
this.dispatchEvent(new CustomEvent('card:expand', {
detail: { id: this.id },
bubbles: true,
}));
}
}js
document.addEventListener('card:expand', (e) => {
analytics.track('card_expanded', e.detail);
});Attributes as the inbound state API
Observed attributes are the HTML-native equivalent of props. Consumers change behavior declaratively or programmatically by setting attributes.
js
class MyDropdown extends HTMLElement {
static observedAttributes = ['open', 'mode', 'disabled'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open') this._syncOpenState();
if (name === 'mode') this._syncMode();
}
}html
<my-dropdown open mode="menu"></my-dropdown>js
document.querySelector('my-dropdown').setAttribute('mode', 'combobox');Methods as the imperative API
For one-shot actions or async operations that don't fit attributes.
js
class MyCard extends HTMLElement {
async flash() {
this.setAttribute('flashing', '');
await new Promise(r => setTimeout(r, 600));
this.removeAttribute('flashing');
}
}js
const card = document.querySelector('my-card');
await card.flash();Hook properties for pluggable policy
Expose function-valued properties consumers can swap. Strategy pattern without subclassing — the component owns the wiring, the consumer owns the policy.
js
class MyDropdown extends HTMLElement {
// Default policy, overridable per instance
filterOptions = (query, options) =>
options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()));
_renderList(query) {
const visible = this.filterOptions(query, this._options);
/* render visible */
}
}js
const dropdown = document.querySelector('my-dropdown');
dropdown.filterOptions = (q, opts) => fuzzyMatch(q, opts);Slots for structural injection
Let consumers inject their own elements into named regions of your layout.
html
<template name="my-dialog">
<header part="header"><slot name="title"></slot></header>
<div part="body"><slot></slot></div>
<footer part="actions"><slot name="actions"></slot></footer>
</template>
<script name="my-dialog">
class MyDialog extends HTMLElement {
#initialized = false;
connectedCallback() {
if (this.#initialized) return;
this.#initialized = true;
const template = document.querySelector('template[name="my-dialog"]');
this.append(template.content.cloneNode(true));
}
}
customElements.define('my-dialog', MyDialog);
</script>html
<my-dialog>
<h2 slot="title">Confirm deletion</h2>
<p>This cannot be undone.</p>
<button slot="actions">Cancel</button>
<button slot="actions">Delete</button>
</my-dialog>Mixins for cross-cutting behavior
When you genuinely need to share behavior across unrelated components, the class-mixin pattern composes without forcing a hierarchy.
js
const Focusable = (Base) => class extends Base {
connectedCallback() {
super.connectedCallback?.();
if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
}
};
class MyCard extends Focusable(HTMLElement) {
connectedCallback() {
super.connectedCallback();
/* card-specific init */
}
}When subclassing actually is right
When the subclass genuinely is-a the parent and you want polymorphic substitutability — everywhere the parent works, the child should work too.
js
class IconButton extends MyButton {
connectedCallback() {
super.connectedCallback();
this.prepend(this._iconElement());
}
}This passes the Liskov test: IconButton is substitutable for MyButton anywhere. If your extension doesn't pass that test, use one of the seams above instead.
The contract at a glance
| Surface | Mechanism |
|---|---|
| Visual tokens | --my-card-* custom properties |
| Structural targeting | [part="..."] attributes |
| Variants | element attributes (featured, size="lg") |
| State | attributes the component reflects (open, loading) |
| Visual reset | all: revert-layer inside components.visual |
| Outbound behavior | CustomEvents |
| Inbound state | observed attributes |
| Imperative actions | methods |
| Pluggable policy | hook properties |
| Structural injection | slots |
| Cross-cutting behavior | class mixins |
| Polymorphic types | subclassing (rarely) |
Nothing in this contract requires the consumer to subclass, learn a parallel naming system, or read your source. Everything is discoverable from the DOM and the documented part names.