React

Verified against Claude · 2026-07-31

Build a polymorphic component that renders as any element without losing types

Implements an asChild-style polymorphic component that merges its props and ref onto the consumer's chosen child element instead of wrapping it in an extra DOM node, with TypeScript generics that keep the merged props fully typed.

Claude CodeCursorChatGPTClaude4 fillable variables

The prompt

Ready to copy — highlighted parts are example details you can swap.

You are building a polymorphic component that can render as different underlying elements depending on what the consumer needs, using an asChild-style pattern that merges props and refs onto the consumer's own child element instead of wrapping it in an extra DOM node.

COMPONENT
Button

TARGET USE CASES
A Button that is sometimes a real <a> tag for navigation links styled identically to buttons, and sometimes a real <button> for in-page actions.

UNDERLYING ELEMENT OPTIONS
button (default), a (via asChild with a Link or anchor child), and occasionally a custom RouterLink component from the routing library.

TYPESCRIPT STRICTNESS
Strict — no any, no unchecked type assertions; props specific to the anchor case (href) must only be allowed when rendering as an anchor.

IMPLEMENTATION RULES
When asChild is true, clone the single child element passed in and merge this component's own props, event handlers, and className onto it, rather than rendering the component's default element around the child — the entire point of asChild is that no extra DOM node gets introduced, which matters for cases like a styled Button that needs to actually be an anchor tag for semantics and SEO, not a button wrapping an anchor. Merge event handlers by calling both the child's original handler and this component's own handler, in that order, rather than letting one silently overwrite the other — a consumer who passed an onClick to the child element should never have it silently discarded because the parent component also wanted to attach one. Forward the ref correctly so a consumer's own ref to the child element still works after the merge; a naive clone that doesn't handle ref forwarding will leave the consumer's ref pointing at nothing. Type the component so that when asChild is used, the props allowed on the component still make sense for whatever element the consumer actually passed as the child — do not let the type system silently widen to any so a consumer loses autocomplete and type-checking on the merged result. When asChild is false or omitted, render the component's own default element normally, fully typed for exactly that element's real DOM attributes and ARIA properties, not a generic React.HTMLAttributes<HTMLElement> catch-all that would accept invalid props. Warn, in development only, if asChild is true and the child is not a single valid React element — zero children, multiple children, or a plain text node — rather than letting React.cloneElement throw its own generic internal error with no indication of which component or which usage site caused it.

OUTPUT FORMAT
1. The component implementation, including the asChild branch, as real code.
2. The TypeScript types, showing how props stay meaningful in both the default-element and asChild cases.
3. Two usage examples: one using the default element, one using asChild to render as a different element.
4. Any limitation of this specific implementation you are aware of — for example, that asChild requires exactly one child element and will need to fail loudly, not silently, if given zero or more than one.

Customize

Optional — swap in your own details for the highlighted parts above.

Why this works

The no-extra-DOM-node rule is the entire reason asChild exists rather than a simpler wrapper approach, and it has a real, concrete consequence: a Button that wraps its child in its own button element instead of merging onto it produces invalid HTML the moment the child is itself an interactive element like an anchor — an interactive element nested inside another interactive element — which breaks keyboard navigation and screen-reader semantics in ways a purely visual check will never catch, since it renders and looks correct while being structurally wrong underneath. Requiring both the child's original handler and the component's own handler to fire, in that order, targets a specific and easy-to-miss regression: a consumer who passes onClick to the anchor they're using as the child expects that handler to still run, and a naive prop-merge implementation that does {...childProps, ...componentProps} will silently let the component's handler replace the consumer's without either side ever raising an error, so the bug only surfaces as "my click handler stopped firing" days after the component shipped. The typing requirement — that props stay meaningful for whichever element is actually rendered, rather than collapsing to a generic any or a catch-all HTML attributes type — is what keeps a polymorphic component's ergonomics from silently degrading over time: without it, every consumer loses real autocomplete and compile-time checking on exactly the component they're most likely to reach for repeatedly across a design system, which is the opposite of what a design-system primitive is supposed to deliver. The development-only warning for an invalid asChild child matters because React.cloneElement's own failure mode here is unhelpful by default: it throws a generic error about the argument it received, with no reference to which design-system component or which call site in the consumer's code triggered it, so a consumer debugging a crash three components away from their own usage of Button asChild has to work backward through a stack trace instead of reading a message that names the actual constraint that was violated and where.

What you get back

type ButtonOwnProps = { asChild?: boolean; variant?: 'primary' | 'secondary' }; type ButtonProps<T extends React.ElementType = 'button'> = ButtonOwnProps & Omit<React.ComponentPropsWithRef<T>, keyof ButtonOwnProps>; function Button<T extends React.ElementType = 'button'>( { asChild, variant = 'primary', className, onClick, children, ...rest }: ButtonProps<T> ) { const classes = cn(buttonStyles({ variant }), className); if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { className: cn(classes, children.props.className), onClick: (e: React.MouseEvent) => { children.props.onClick?.(e); onClick?.(e as any); }, ...rest, }); } return <button className={classes} onClick={onClick} {...rest}>{children}</button>; } // Default: <Button onClick={save}>Save</Button> // Polymorphic: <Button asChild><a href="/docs">Read the docs</a></Button> Limitation: asChild requires exactly one valid React element as a child — passing zero children or a text node throws a descriptive error rather than failing silently on React.cloneElement.

Verified against

Claude Sonnet 4.6 · 2026-07-31

ChatGPT GPT-5.1 · 2026-08-05

Changelog

  • 2026-07-31 Initial publish, verified against Claude (Sonnet 4.6) and ChatGPT (GPT-5.1).

Need this built into your business?

If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.

EXPLORE CUSTOM SOFTWARE
All React prompts

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY