Skip to content

Latest commit

 

History

History
351 lines (255 loc) · 10.8 KB

File metadata and controls

351 lines (255 loc) · 10.8 KB

react-polymorphic

CI npm version license

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?

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.

  • root Override for Composition
    Control the final rendered element when nesting polymorphic components.

  • Type-safe Inherited Prop Control
    Use PolymorphicConfig with Inherit and Unset to preserve, remove, or reshape inherited props.

  • Ref Inference that Follows Render Target
    ref types track the active element/component target.

  • Inference Utilities Included
    InferPolymorphElement, InferPolymorphProps, and InferPolymorphConfig for 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 of ref, eliminating forwardRef and convoluted type gymnastics.

  • ESM Exclusive
    No CommonJS, no legacy build artifacts. Tree-shakable and optimized for modern bundlers.

Installation

npm install react-polymorphic

Use 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>
    </>
  )
}
  • Unset removes an inferred prop from the resulting contract.
  • Inherit preserves the inferred type (useful when making a prop required or composing unions).
  • Adding href and target to Config prepares the component for common as="a" usage.
  • PolymorphicConfig is 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:run

PR requirements

  • Keep changes focused and include tests when behavior changes.
  • Add a changeset for all user-facing package changes:
pnpm changeset

When prompted, choose the correct bump type:

  • patch → fixes and small improvements
  • minor → new backward-compatible features
  • major → breaking changes

If a PR does not require a release, you can create an empty changeset:

pnpm changeset --empty

Automation

  • Pre-commit runs biome check --write on staged files.
  • Pre-push runs pnpm typecheck and pnpm 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.

License

Apache-2.0. See LICENSE and NOTICE.