Polymorphic Components
Date: 2026-08-16
One component that can render as different HTML elements without changing its API. It exists because appearance and semantics are separate decisions, and a system that fuses them forces people to choose between looking right and being correct.
A polymorphic component lets the consumer choose the underlying element while keeping the component’s styling and behaviour.
<Button>Save</Button>
→ <button class="btn">Save</button>
<Button as="a" href="/next">Continue</Button>
→ <a href="/next" class="btn">Continue</a>Same component, same styles, different element — and crucially, different semantics.
The problem it solves
Without it, a link that should look like a button leaves two bad options:
OPTION 1 use <Button onClick={navigate}>
looks right
✗ not a link — no middle-click,
no open-in-new-tab, no href
for a crawler, wrong role
for a screen reader
OPTION 2 copy the button's CSS onto an <a>
correct semantics
✗ a fork. Diverges on the next
design change
Both are common, and both are the system failing. Polymorphism removes the choice: the element is a semantic decision, the styling is a system decision, and they stop competing — Semantic HTML.
Where it genuinely earns its place
Button → button · a · (rarely) label
Text → p · span · h1–h6 · div
Stack/Box → div · section · ul · nav
Card → div · article · li · a
The layout primitives are the strongest case. A Stack that always renders <div> produces pages made entirely of divs, which is exactly the outcome semantic HTML exists to prevent.
The rules that make it safe
Restrict the allowed elements. as accepting any string means <Button as="table"> compiles.
allowed: "button" | "a"
That’s usually a short list, and constraining it is the difference between a feature and a hole — Component API Design.
Forward the right props. as="a" must accept href, target, rel; as="button" must accept type, disabled. In a typed language this should follow automatically from the chosen element rather than being a union of everything — Type Systems.
Keep accessibility correct per element. A <div> styled as a button needs role="button", tabindex="0" and keyboard handlers to be usable at all — which is a strong argument for not allowing as="div" in the first place. If the allowed list only contains elements that are already interactive, this problem disappears.
asChild — the other pattern
Rather than a string, pass the element as a child and have the component merge onto it:
<Button asChild>
<Link href="/next">Continue</Link>
</Button>The button’s props, classes and behaviour are merged onto the Link. This is the better answer when the element you need is itself a component — a router link, an analytics-wrapped anchor — which a string as can’t express cleanly.
as="a" good for plain HTML elements
asChild good for other components
The cost is that asChild requires exactly one child element and fails confusingly otherwise, and prop merging (especially className and event handlers) has to be handled deliberately.
Where it goes wrong
- Unconstrained
as. Every element becomes possible and the component’s guarantees stop meaning anything - Styling that assumes an element. CSS written against
button { }breaks the momentas="a"is used. Style the class, not the tag - Losing default behaviour.
<button>submits a form;<a>doesn’t. A polymorphic component silently changes behaviour, and that’s correct — but it needs documenting, because the surprise is real — Component Documentation - Using it to avoid a second component. If two elements need genuinely different behaviour rather than the same behaviour on a different tag, they’re two components.
asis for changing the tag, not for merging unrelated things
The underlying idea
This is Coupling and Cohesion applied to markup: appearance, semantics and behaviour are three concerns, and a component that fuses them forces one decision to be made for the wrong reason. Polymorphism separates the first two; Headless Components separates the third.