Skip to main content
Version: v5 (Preview)

Component Archetypes

A Stencil component can take four shapes, ranging from a styled tag with no code at all to a fully encapsulated custom element. Pick the lightest one that covers the behavior a tag needs: a shadow-rooted class with a full lifecycle is more to write, test, and reason about than a stylesheet.

One thing holds across all four: every archetype is fully typed and documented the same way. Stencil's static analysis - docs-json, docs-readme, the Custom Elements Manifest - reads a component's real shape (a class's decorators, or a CSS file's JSDoc tags) regardless of which archetype produced it. A consumer gets the same JSX types and generated docs whether a tag is backed by a full class or nothing at all.

CSS-only​

No JS, no class, never registered. The tag exists so a stylesheet has something to attach to - Stencil reads a /** @component */-annotated CSS file and produces a documented, type-checked tag from it directly.

/**
* @component
* A colored status badge.
*/
my-badge {
color: var(--badge-color, black);
}
my-badge[variant='danger'] {
--badge-color: red;
}
<my-badge variant="danger">Sale</my-badge>

Reach for this when a tag is purely presentational - a badge, a divider, a layout primitive - with no state and nothing to react to. See CSS-Only Components for the full authoring reference.

Progressively-enhanced​

Markup that already means something before any JS runs; JS adds to it, rather than creating it. The component author writes real semantic HTML as the tag's own light-DOM content - not JS-rendered output - and a Stencil class (typically encapsulation: { type: 'none' }) enhances that markup once it loads: adding an event listener, a keyboard shortcut, an animation. If the JS never loads, the markup still works as plain HTML.

@Component({ tag: 'share-link', encapsulation: { type: 'none' } })
export class ShareLink {
private onClick = (e: MouseEvent) => {
const link = (e.target as Element).closest('a');
if (!link || !navigator.share) return; // plain link still works without this
e.preventDefault();
navigator.share({ url: link.href });
};

render() {
return (
<Host onClick={this.onClick}>
<slot />
</Host>
);
}
}
<share-link>
<a href="https://example.com/post/1">Share this post</a>
</share-link>

The <a> is real, author-written markup - a search engine, a screen reader, or a browser with JS disabled all still get a working link. ShareLink's class only intercepts the click when the richer Web Share API is actually available. Reach for this when a tag's core job already exists in HTML and JS is strictly additive.

Custom element, no shadow DOM​

Real state and rendering, without shadow isolation. The full component model - props, state, lifecycle, render() - with encapsulation: { type: 'scoped' } or { type: 'none' } (none is the default). Unlike the progressively-enhanced case, the rendered output is generated by Stencil; there's no meaningful fallback if the JS doesn't load. What you give up is shadow DOM's hard style and DOM boundary - useful when a component needs to participate in the page's own styling (a design system component meant to inherit surrounding typography, say) or needs light-DOM children a parent framework manages directly.

@Component({
tag: 'inline-alert',
styleUrl: 'inline-alert.css',
encapsulation: { type: 'scoped' },
})
export class InlineAlert {
@Prop() variant: 'info' | 'warning' = 'info';

render() {
return (
<Host role="alert">
<slot />
</Host>
);
}
}

See Choosing an Encapsulation Strategy and Slots Without Shadow DOM for the styling and slot mechanics this archetype relies on.

Custom element, shadow DOM​

The traditional, fully encapsulated component - encapsulation: { type: 'shadow' }, which you opt into explicitly. Real <slot>, a genuine style and DOM boundary, and the most isolation from whatever page the component ends up on. Reach for it whenever a component's internals - its own DOM structure, its own styles - shouldn't be something the surrounding page can accidentally affect or depend on.

@Component({
tag: 'user-card',
styleUrl: 'user-card.css',
encapsulation: { type: 'shadow' },
})
export class UserCard {
@Prop() name: string;

render() {
return (
<Host>
<h3>{this.name}</h3>
<slot />
</Host>
);
}
}

See encapsulation for the full option reference, including mode: 'closed' and the other shadow-specific settings this archetype alone supports.