Tags: web-dev concept

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 moment as="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. as is 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.