react-polymorphic
v1.0.3
Published
Create polymorphic React 19+ components with strong TypeScript inference.
Maintainers
Readme
react-polymorphic
Great to Perfect. The catalyst for your design-system.
A single component factory function for polymorphic React components. Built for React 19+ with strong TypeScript inference.
import { createPolymorph } from 'react-polymorphic';
const Button = createPolymorph<'button'>(({ as: Tag = 'button', ...rest }) => {
return <Tag {...rest} />
})
const App = () => {
return <Button as="a" href="/">Home</Button>
}Table of Contents
- Why Polymorphic Components?
- Features
- Installation
- Examples
- API
- Compatibility
- Contributing + Changesets
- License
Why Polymorphic Components?
Design systems need components that are reusable without being rigid.
A <Button /> might need to render as a native <button /> in one context and as an <a /> in another. A heading component may need to shift from <h1 /> to <h3 /> based on document hierarchy.
Polymorphic components let you keep one consistent API while swapping the rendered element to match context (button -> a, div -> section, h1 -> h3), preserving semantics, accessibility, and developer ergonomics.
react-polymorphic is built to take this beyond a basic as prop, with an API that gives you precise control over inherited props, component composition, and render behavior.
Features
Polymorphism Beyond
as
Supports intrinsic elements, React components, and composed polymorphic components.rootOverride for Composition
Control the final rendered element when nesting polymorphic components.Type-safe Inherited Prop Control
UsePolymorphicConfigwithInheritandUnsetto preserve, remove, or reshape inherited props.Ref Inference that Follows Render Target
reftypes track the active element/component target.Inference Utilities Included
InferPolymorphElement,InferPolymorphProps, andInferPolymorphConfigfor advanced typing workflows.TypeScript 7 Ready
Compatible with TypeScript 5.4+ and optimized for TypeScript 7.Built for React 19+
Leverages React 19's handling ofref, eliminatingforwardRefand convoluted type gymnastics.ESM Exclusive
No CommonJS, no legacy build artifacts. Tree-shakable and optimized for modern bundlers.
Installation
npm install react-polymorphicUse the equivalent command for your package manager of choice.
Examples
1 — Basic Usage
Most polymorphic components are simple wrappers that combine custom props with inferred DOM attributes.
import { createPolymorph } from 'react-polymorphic';
type Props = {
variant: 'primary' | 'secondary';
}
const Pane = createPolymorph<'div', Props>((props) => {
const { as: Tag = 'div', variant, className, ...rest } = props;
const variantClasses = variant === 'primary' ? 'bg-white' : 'bg-blue-50';
return <Tag className={`p-4 ${variantClasses} ${className}`} {...rest} />
})
const App = () => {
return (
<>
<Pane as="section" variant="primary" aria-label="...">...</Pane>
<Pane as="aside" variant="secondary" aria-label="...">...</Pane>
</>
)
}Props and the intrinsic attributes of 'div' are merged into one contract. Consumers can then switch semantics with as (section, aside, etc.) while keeping the same component API.
2 — Using ref
ref is inferred from the active render target. With React 19, you can use ref directly without forwardRef.
import { useRef } from 'react';
import { createPolymorph } from 'react-polymorphic';
const Component = createPolymorph<'div'>((props) => {
const { as: Tag = 'div', ...rest } = props;
return <Tag {...rest} />
})
const App = () => {
const ref = useRef<HTMLDivElement>(null);
const aRef = useRef<HTMLAnchorElement>(null);
return (
<>
<Component ref={ref} />
<Component as="a" ref={aRef} />
</>
)
}ref follows as automatically (and also follows root when composing with polymorphic components).
3 — Config & Branded Types
Use PolymorphicConfig to reshape inferred props: remove, preserve, override, or require attributes.
import { createPolymorph } from 'react-polymorphic';
import type { PolymorphicConfig, Unset, Inherit } from 'react-polymorphic';
type Props = {
loading: boolean;
}
type Config = PolymorphicConfig<'button', {
'aria-loading': Unset;
children: string;
href: string;
target?: '_blank';
type: Inherit;
}>;
const Button = createPolymorph<'button', Props, Config>((props) => {
const { as: Tag = 'button', children, loading, ...rest } = props;
return (
<Tag aria-loading={loading} {...rest}>
{loading ? 'Loading...' : children}
</Tag>
)
})
const App = () => {
return (
<>
<Button type="button">Calculate</Button>
<Button as="a" href="/">Back to home</Button>
</>
)
}Unsetremoves an inferred prop from the resulting contract.Inheritpreserves the inferred type (useful when making a prop required or composing unions).- Adding
hrefandtargettoConfigprepares the component for commonas="a"usage. PolymorphicConfigis optional, but recommended for code hints and safer attribute authoring.
4 — Advanced as Composition with root
When as points to another polymorphic component, root lets you control that component's final underlying element.
import { createPolymorph } from 'react-polymorphic';
const OtherComponent = createPolymorph<'div'>(...);
const Component = createPolymorph<'div'>((props) => {
const { as: Tag = 'div', ...rest } = props;
return <Tag {...rest} />
});
const App = () => {
return <Component as={OtherComponent} root="section" />
}root behaves similarly to key: it influences rendering behavior but is not part of the consumed props inside your render function.
5 — Advanced Composition & Inference
You can extract generics from existing polymorphic components and reuse them when extending third-party components.
import { createPolymorph } from 'react-polymorphic';
import type {
InferPolymorphElement,
InferPolymorphProps,
InferPolymorphConfig
} from 'react-polymorphic';
import { TheirButton } from 'some-library';
type TheirElement = InferPolymorphElement<typeof TheirButton>;
// 'button'
type TheirProps = InferPolymorphProps<typeof TheirButton>;
// { message: string }
type TheirConfig = InferPolymorphConfig<typeof TheirButton>;
// { id?: number; title: string }
type Props = { ... }
const OurButton = createPolymorph<typeof TheirButton, Props>((props) => {
const { as: Tag = TheirButton, ...rest } = props;
return <Tag {...rest} />
})This pattern keeps your extension aligned with the base component contract while still allowing consumers to swap as.
If swapping out the base would break core behavior, preserve the base and expose only its internal polymorphism:
const OurButton = createPolymorph<TheirElement, Props & TheirProps, TheirConfig>(
(props) => {
const { as = 'button', ...rest } = props;
return <TheirButton as={as} {...rest} />
}
)
const App = () => {
return <OurButton as="a" />
}In this version, OurButton always renders TheirButton, and as only controls what TheirButton itself renders to the DOM.
API
createPolymorph
Creates polymorphic components with strong inference for as, root, and ref.
| Export | Kind |
| ----------------- | ---------- |
| createPolymorph | function |
Component Props
| Prop | Type | Notes |
| ------ | ---------------------------------- | ------------------------ |
| as? | element | component | Swaps target. |
| root? | element | Fragment | Overrides nested target. |
| ref? | inferred from final target | Follows as/root. |
Generics
| Generic | Meaning |
| ------- | ------------------------------------ |
| T | Default render target. |
| P | Custom component props. |
| C | Prop config via PolymorphicConfig. |
Utility Types
| Export | Purpose |
| -------------------------- | ------------------------------ |
| PolymorphicConfig<T, C> | Configures inherited props. |
| Inherit | Preserves inferred prop type. |
| Unset | Removes an inferred prop. |
| InferPolymorphElement<T> | Extracts default target type. |
| InferPolymorphProps<T> | Extracts custom props type. |
| InferPolymorphConfig<T> | Extracts config type. |
Compatibility
| Package | Version |
| ------------ | ---------- |
| react | >=19.0.0 |
| react-dom | >=19.0.0 |
| typescript | >=5.4.0 |
Contributing + Changesets
Contributions are welcome. For bug fixes, improvements, or new features, please open a PR with a clear summary of the change.
Local setup
pnpm install
pnpm typecheck
pnpm test:runPR requirements
- Keep changes focused and include tests when behavior changes.
- Add a changeset for all user-facing package changes:
pnpm changesetWhen prompted, choose the correct bump type:
patch→ fixes and small improvementsminor→ new backward-compatible featuresmajor→ breaking changes
If a PR does not require a release, you can create an empty changeset:
pnpm changeset --emptyAutomation
- Pre-commit runs
biome check --writeon staged files. - Pre-push runs
pnpm typecheckandpnpm test:run. - CI validates formatting, linting, type-checking, tests, and build.
- Release runs only after CI succeeds on
main, using Changesets to version and publish.
