Extending components
Patterns for extending Primer React components beyond what their props expose. Covers the `as` prop for polymorphic rendering, the slot system for positioned composition, and wrapping for behavior layering.
When a Primer component doesn't quite cover what you need, there are three mechanisms you can reach for. Each suits a different shape of problem.
| Pattern | Use it when you want to |
|---|---|
as prop | Change the underlying DOM element or component, but keep all the styling and behavior. |
| Slot system | Inject content into a specific position inside a compound component. |
| Wrapping | Layer behavior, defaults, or context onto a component. Takes extra care if a slot is involved. |
This page covers all three and how to choose between them.
Pattern 1: the as prop
Most Primer components are polymorphic. They render a default element (a <button> for <Button>, an <h1> for <Heading>), but accept an as prop that changes the underlying element or component without losing the styling and behavior layered on top.
<Button>Submit</Button> // renders <button>
<Button as="a" href="/docs">Read docs</Button> // renders <a href="/docs">
<Button as={NextLink} href="/docs">Read docs</Button> // renders Next's <Link>
How it works internally
Polymorphic components are declared with Primer's ForwardRefComponent type, kept at src/utils/polymorphic.ts. It was originally forked from Radix's polymorphic helper.
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <ButtonBase ref={ref} as="button" {...props} />
}) as PolymorphicForwardRefComponent<'button', ButtonProps>
Two things happen here:
- TypeScript typing. The
PolymorphicForwardRefComponent<'button', ButtonProps>overload generates a polymorphic call signature.<Button as="a" href={…} />is typed as ifButtonaccepted all anchor props, andhrefbecomes required whenas="a". - Runtime forwarding. The underlying
ButtonBasereads theasprop and uses it as the JSX element type. The component you authored stops controlling the element type and forwards that decision to the consumer.
When to use it
- Render a
Buttonas a link without losing its visual treatment (<Button as="a" href="…">). - Wire a Primer button into your router's
Linkcomponent (<Button as={NextLink} to="/page">). - Use a heading style at a different semantic level (
<Heading as="h3">).
When not to use it
- You want to change content positioning inside a compound component (for example, add a leading icon to an
ActionList.Item). That's a slot, not a polymorphic element. - You want to add a default prop or piece of context that applies across many call sites. That's a wrapper. Keep reading.
Radix's <Slot asChild> is a closely related pattern. Instead of naming the element type via as="a", the consumer wraps the desired child and the parent merges its props onto it. Both solve the same problem with different ergonomics; as is more familiar to teams coming from Material UI or Chakra.
Pattern 2: the slot system
Many compound components (ActionList, FormControl, Dialog, PageLayout, PageHeader, and others) accept named "slot" children that they extract and render in specific positions. Compare:
// Props-based. Explicit, but gets unwieldy as you add more positions.
<ActionList.Item
leadingVisual={<CheckIcon />}
description="Status detail"
>
Approved
</ActionList.Item>
// Slot-based. Reads top-to-bottom; props live with the slot they belong to.
<ActionList.Item>
<ActionList.LeadingVisual><CheckIcon /></ActionList.LeadingVisual>
Approved
<ActionList.Description>Status detail</ActionList.Description>
</ActionList.Item>
The slot-based form composes like HTML and keeps per-slot props attached to the slot itself. To make that work the parent has to inspect its children and figure out which one is the leading visual, which is the description, and which is the body text, because they're all just elements in children.
How slot matching works
When a Primer compound component receives children, it walks them once and matches each against a config of {slot-name: Component}. For each child, it uses two strategies in order:
- Direct component reference.
child.type === ActionList.LeadingVisual. The child is literally rendered from the slot component. - Slot marker symbol.
child.type.__SLOT__ === ActionList.LeadingVisual.__SLOT__. The child is rendered from a different component that has copied the marker symbol from the slot it's emulating.
If neither check matches, the child falls into the rest bucket and renders in the default content position.
The framework that powers this is the useSlots hook (reference), used internally by Primer:
const [slots, rest] = useSlots(children, {
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
description: Description,
})
// slots.leadingVisual, slots.trailingVisual, slots.description are the matched children.
// rest is everything else, in original order (the body text in the example above).
The same matching applies whether the parent is ActionList.Item, FormControl, Dialog, PageLayout, or PageHeader. Each configures different slot names.
Pattern 3: wrapping
The third extension mechanism is the most common React pattern. Write a wrapper component that pre-fills props, layers in a context, or composes Primer pieces together. Most of the time this just works:
function MyDialog(props) {
return <Dialog {...props} themedFor="settings" />
}
The one place wrapping silently breaks is when you wrap a slot component:
// A thin wrapper around ActionList.LeadingVisual that adds a default color.
function ColoredLeadingVisual({color, children}) {
return (
<ActionList.LeadingVisual>
<span style={{color}}>{children}</span>
</ActionList.LeadingVisual>
)
}
<ActionList.Item>
<ColoredLeadingVisual color="red"><CheckIcon /></ColoredLeadingVisual>
Approved
</ActionList.Item>
ActionList.Item sees a child whose type is ColoredLeadingVisual, not ActionList.LeadingVisual, and falls through to the symbol check. ColoredLeadingVisual doesn't have a __SLOT__ symbol either, so it goes into rest and renders inline with the body text instead of in the leading-visual column.
The demo below shows the failure mode and both fixes side-by-side:
Broken wrapper
Working wrapper
Native (no wrapper)
In the leftmost column the icon sits above the body text. That's the slot falling into rest. In the middle column the wrapper has a one-line __SLOT__ copy and renders identically to the rightmost (native) <ActionList.LeadingVisual>.
Three remediations for slot wrappers
Pick based on what the wrapper actually needs to do.
Re-export when the wrapper adds nothing
If your "wrapper" is just a rename (for example, exposing ActionList.Description under your own component's namespace), don't wrap. Re-export:
import {ActionList} from '@primer/react'
export const MyList = Object.assign(MyListRoot, {
Item: ActionList.Item, // identity preserved
Description: ActionList.Description,
})
<MyList.Description> is literally <ActionList.Description>, so the direct-reference check matches with no special treatment. This is almost always the right choice when the wrapper exists purely for API consolidation.
Copy the __SLOT__ symbol when the wrapper adds behavior
If the wrapper genuinely needs to do something (add a default prop, layer in a context, swap out the inner element), copy the marker so the parent still recognizes it as the same slot. This is what makes the difference between the leftmost and middle columns in the demo above.
import {ActionList} from '@primer/react'
function ColoredLeadingVisual({color, children}) {
return (
<ActionList.LeadingVisual>
<span style={{color, display: 'inline-flex'}}>{children}</span>
</ActionList.LeadingVisual>
)
}
// Forward the marker so ActionList.Item's slot scan finds ColoredLeadingVisual too.
ColoredLeadingVisual.__SLOT__ = ActionList.LeadingVisual.__SLOT__
Copying __SLOT__ declares that your component is this slot. The wrapper takes full ownership of what gets rendered in that position. It doesn't have to render the original slot component inside it. Most wrappers do, because that's how they inherit the slot's built-in styles, ARIA, and layout, but if you have a reason to render something completely different, the slot system won't stop you.
The same pattern works for any Primer slot: FormControl.Label, Dialog.Header, PageLayout.Sidebar, PageHeader.ContextArea. Wherever a wrapper needs to be the slot, copy the marker.
Own your own useSlots boundary when you're authoring slots
If you're building a compound component that introduces its own slot API, don't try to extend Primer's slot scanner. Run the same kind of children scan yourself and route the matched children where they belong.
For example, suppose you want a SectionHeader that supports an "eyebrow" line above the title. PageHeader doesn't have an eyebrow slot, so you introspect your own children, find the eyebrow, and render it where you want:
import {Children, isValidElement} from 'react'
import {PageHeader} from '@primer/react'
function Eyebrow({children}) {
return <span className="eyebrow">{children}</span>
}
Eyebrow.__SLOT__ = Symbol('SectionHeader.Eyebrow')
// Match by direct reference OR by __SLOT__ symbol, so wrappers around
// SectionHeader.Eyebrow are recognised too. Same matcher pattern Primer's
// own useSlots hook uses.
function matchesEyebrow(child) {
if (!isValidElement(child)) return false
return child.type === Eyebrow || child.type?.__SLOT__ === Eyebrow.__SLOT__
}
function SectionHeader({title, children}) {
const childArray = Children.toArray(children)
const eyebrow = childArray.find(matchesEyebrow)
return (
<PageHeader>
<PageHeader.TitleArea>
{eyebrow}
<PageHeader.Title>{title}</PageHeader.Title>
</PageHeader.TitleArea>
</PageHeader>
)
}
SectionHeader.Eyebrow = Eyebrow
main
This is what useSlots does internally. You can use it directly once it ships publicly, or hand-roll Children.toArray plus a reference check until then. Your slot lives at your layer; Primer doesn't need to know about it.
Authoring slot components of your own
When you build a new compound component with named slots, expose a __SLOT__ symbol on every slot so consumers can wrap them later. Combine React.FC with the SlotMarker type from @primer/react:
import type {FC, ReactNode} from 'react'
import type {SlotMarker} from '@primer/react'
type LabelProps = {children: ReactNode}
const Label: FC<LabelProps> & SlotMarker = ({children}) => (
<span className="my-label">{children}</span>
)
Label.__SLOT__ = Symbol('MyField.Label')
export const MyField = Object.assign(MyFieldRoot, {Label})
The symbol value doesn't matter. What matters is that Label.__SLOT__ is the same symbol every time the module is evaluated, and that consumers compare against MyField.Label.__SLOT__ rather than the literal symbol value.
Choosing between the three patterns
A short decision guide:
- To render a different HTML element or component while keeping the rest of the styling and behavior, use the
asprop. - To inject content at a specific named position inside a compound component (header, leading visual, description), use a slot.
- To apply a default prop, context, or side effect every time a component is used, wrap the component. If the component being wrapped is a slot, also copy
__SLOT__. - To expose a new compound API that combines Primer pieces with your own slot positions, own the
useSlotsboundary yourself.
The three patterns compose with each other. A single component can be polymorphic, accept slot children, and be wrapped, all at once.
Gotchas
Slot matching is single-pass, first-match-wins
If two children both match the same slot, only the first is used. The second produces a dev-mode warning ("Found duplicate "label" slot. Only the first will be rendered.") and is silently dropped from rest. Don't render two <FormControl.Label> elements as siblings.
Each module instance has its own slot symbol
Symbol() is unique per call. If you have two copies of @primer/react loaded (for example, duplicate dependencies in a monorepo), their __SLOT__ values won't be equal. This usually shows up as "slots stop matching after I added a workspace dependency". npm ls @primer/react is the first thing to check.
__SLOT__ is a runtime property, not a type-level marker
Forgetting to attach Wrapper.__SLOT__ = Original.__SLOT__ doesn't produce a TypeScript error. The wrapper silently fails to be detected at runtime. If you're shipping a slot wrapper, write a test that asserts the wrapper is matched by the parent component's slot scanner.
Wrapping a polymorphic component drops the polymorphic type
A simple wrapper like function MyButton(props) { return <Button {...props} /> } discards the polymorphic call signature. <MyButton as="a" href="…"> will type-check against Button's base props only, not the merged anchor props. To preserve polymorphism through a wrapper, declare the wrapper itself with PolymorphicForwardRefComponent:
const MyButton = forwardRef<HTMLElement, MyButtonProps>(({as = 'button', ...props}, ref) => {
return <Button ref={ref} as={as} {...props} />
}) as PolymorphicForwardRefComponent<'button', MyButtonProps>
Related
useSlotshook reference. The slot matching algorithm in detail.ActionListcomponent. Slot-based item composition (LeadingVisual,TrailingVisual,Description).FormControlcomponent. Slot-based form fields (Label,Caption,Validation).Dialogcomponent.Header,Body,Footerslots.Buttoncomponent. Canonicalasprop usage.- Radix Slot (
asChildpattern). Alternative ergonomics for consumer-driven root rendering. - Vue named slots. Context on compile-time slot syntax in another framework.