Typed variants and merge helpers still assume the host element is stable -
usually a <button> or <div>. Product UI asks for more: the same visual
recipe as a link, a span inside a menu item, or a Radix trigger.
Polymorphism (as, asChild) is how design-system primitives stay thin without
forking ButtonLink, ButtonSpan, and ButtonTrigger. The craft is pricing
the typing and a11y cost before you expose the escape hatch.
Two common shapes
as prop: consumer picks the element type; you forward props and refs.
tsx// packages/ui/src/text.tsx, illustrative as-prop import { type ElementType, type ComponentPropsWithoutRef, type ElementRef, forwardRef, } from 'react'; type TextOwnProps<T extends ElementType> = { as?: T; tone?: 'default' | 'muted'; }; type TextProps<T extends ElementType> = TextOwnProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof TextOwnProps<T>>; function TextInner<T extends ElementType = 'p'>( { as, tone = 'default', className, ...props }: TextProps<T>, ref: React.ForwardedRef<ElementRef<T>>, ) { const Comp = as ?? 'p'; return ( <Comp ref={ref as never} data-tone={tone} className={className} {...props} /> ); } export const Text = forwardRef(TextInner) as <T extends ElementType = 'p'>( props: TextProps<T> & { ref?: React.Ref<ElementRef<T>> }, ) => React.ReactElement | null;
asChild (slot): consumer supplies the element; you merge props onto the
child (Radix Slot pattern). No second DOM node.
tsx// illustrative asChild, behavior sketch import { Slot } from '@radix-ui/react-slot'; type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { asChild?: boolean; variant?: 'primary' | 'ghost'; }; export function Button({ asChild, variant = 'primary', ...props }: ButtonProps) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-variant={variant} {...props} /> ); } // usage, styles/behavior merge onto the link <Button asChild variant="ghost" > <a href="/billing">Billing</a> </Button>;
I reach for asChild when the child must be a specific interactive element
(router Link, menu item). I reach for as when the primitive is mostly
presentational and the element set is small (p | span | label).
Accessibility when the element changes
Changing the tag changes the accessibility tree. That is not a TypeScript problem, it is a product risk.
| Swap | Risk |
|---|---|
button → a | Need href; Enter/Space expectations differ if you fake a button |
button → div | Lose native button semantics unless you rebuild role + keyboard |
label → span | Lose label association; forms break quietly |
h2 → div | Heading outline collapses |
Decision criteria I use in review:
- Does the polymorphic surface allow non-interactive hosts for interactive recipes? If yes, constrain the union or forbid it.
- Are we forwarding
disabled,aria-*, andtypecorrectly for buttons? - Does
asChildmerge event handlers without dropping consumeronClick? Slot implementations must compose, not replace.
tsx// Prefer constrained unions over ElementType for interactive primitives type ButtonAs = 'button' | 'a';
Open ElementType on a Button is how you get <div role="button">
regressions that pass visual QA.
Typing cost versus flexibility
Full polymorphic typing in TypeScript is real work: generic as,
ComponentPropsWithoutRef, ref typing, and often a double assertion. Many teams
ship a narrow polymorphism instead:
Button+ButtonLink(two exports, shared CVA)- or
asChildonly (Radix-style), typed asbuttonprops whenasChildis false
tsx// Narrow alternative, shared variants, explicit hosts const buttonVariants = cva(/* ... */); export function Button(props: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<...>) { return <button className={cn(buttonVariants(props))} {...props} />; } export function ButtonLink(props: ComponentProps<typeof Link> & VariantProps<...>) { return <Link className={cn(buttonVariants(props))} {...props} />; }
I pick the narrow form when the element set is two or three hosts and the
generic as API would dominate the file. The rule I keep: polymorphism is a
product API, not a TypeScript flex.
Decision criteria before exposing polymorphism
Ship as / asChild when:
- Real call sites need ≥2 hosts for the same visual recipe this quarter
- You can constrain the host union (or use Slot) without open
ElementType - Focus, keyboard, and disabled semantics are defined per host
Prefer fixed elements + thin wrappers when:
- Only one host exists in production today
- The team cannot afford the generic typing tax in review
- Juniors would misuse
as="div"for click handlers
tsx// Domain stays explicit, primitives-to-domain altitude still applies import { Button } from '@acme/ui'; import { Link } from '@tanstack/react-router'; export function BillingNavLink( props: Omit<ComponentProps<typeof Link>, 'className'>, ) { return ( <Button asChild variant="ghost" > <Link {...props} /> </Button> ); }
That wrapper is cheaper than teaching every feature to polymorphic-type a button.
Failure modes
as="div" on clickable chrome. Keyboard and SR users lose affordances.
Prop collisions. as="a" still accepting type="submit". Constrain props
per host.
Double wrappers with asChild forgotten. Extra <button><a/></button>
invalid HTML and nested interactive controls.
Ignoring July's a11y library post. If you already standardized on Radix / Base UI slots, inventing a third polymorphism layer is duplication.
Ref measuring the wrong node. After asChild, parent layout code that
assumed a wrapper div measures the child instead, test refs when you claim the
pattern.
Continuity
CVA and tailwind-merge made class recipes typed and merge-safe. Polymorphism asks when the host element is part of the recipe. Composition patterns (render props to hooks) cover when structure beats another prop on the primitive.
Takeaway
Polymorphic as / asChild pays off when it prevents fork components - and
loses it when any element can wear a button's clothes without button semantics.