import { ButtonBase } from '@vega-ui/react';
<ButtonBase asChild variant='secondary' appearance='ghost'>
<a href='#'>Open billing</a>
</ButtonBase>- Browser support
- Widely available
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari on iOS 15.4
- Bundle size
- Oklab and OkLCh — comes from the design tokens
Summary
Slot is the low-level polymorphic composition primitive behind VegaUI asChild APIs. It clones exactly one valid React element and merges parent props into that child.
Imports
import { Slot, type SlotProps } from '@vega-ui/react/Slot';Exported Types
SlotProps<T>
Basic Usage
import type { HTMLAttributes } from 'react';
import { Slot } from '@vega-ui/react';
interface TagProps extends HTMLAttributes<HTMLElement> {
asChild?: boolean
}
const tagStyle = {
display: 'inline-flex',
padding: 'var(--spacing-1) var(--spacing-5)',
border: 'none',
borderRadius: 999,Variants
Slotitself has no visual variants.- Practical variation comes from the parent component that uses
asChild.
Edge Cases
- The child must be exactly one valid React element.
Slotchanges rendering, not semantics; the chosen child element still defines the final HTML semantics.- Prop merging can produce surprising behavior if parent and child both define competing handlers or attributes.
Common Mistakes
- Using
Slotdirectly when a higher-levelasChildAPI already exists. - Passing text or fragments instead of one valid element.
- Swapping semantics unintentionally by slotting the wrong element type.
Examples
Basic
Basic: authoring an asChild component
The standard pattern: render your own element by default and Slot when asChild is set. The same Tag styling lands on a <span>, an <a>, or a <button>, and each keeps its own semantics.
import type { HTMLAttributes } from 'react';
import { Slot } from '@vega-ui/react';
interface TagProps extends HTMLAttributes<HTMLElement> {
asChild?: boolean
}
const tagStyle = {
display: 'inline-flex',
padding: 'var(--spacing-1) var(--spacing-5)',
border: 'none',
borderRadius: 999,Controlled/Stateful
Controlled/Stateful: chained click handlers
Slot does not replace the child's handlers: both run, the slot's first and the child's second. The log shows the order, which matters when a wrapper adds analytics or guards around an existing action.
import { type ReactNode, useState } from 'react';
import { Button, Slot } from '@vega-ui/react';
interface TrackClickProps {
event: string
onTrack(event: string): void
children: ReactNode
}
function TrackClick({ event, onTrack, children }: TrackClickProps) {
return <Slot onClick={() => onTrack(`analytics: ${event}`)}>{children}</Slot>;
}Form/Integration
Form/Integration: field wrapper that wires any control
A Field wrapper passes id, name, and aria-describedby through Slot to whatever control it wraps, so a TextArea and a native <select> get the same label and hint wiring. Slot only reaches its direct child, so wrap the control itself, not a container around it.
import { type FormEvent, type ReactNode, useState } from 'react';
import { Button, HelperText, Label, Slot, TextArea } from '@vega-ui/react';
interface FieldProps {
id: string
label: string
hint: string
children: ReactNode
}
function Field({ id, label, hint, children }: FieldProps) {
return (Layout/Overlay
Layout/Overlay: custom component as a popover trigger
PopoverTrigger asChild passes popoverTarget, ARIA attributes, and a ref to the child. A custom component works only if it spreads those props and the ref onto a native <button>, as AccountChip does.
import type { ButtonHTMLAttributes, Ref } from 'react';
import { Button, Popover, PopoverContent, PopoverTrigger, Text } from '@vega-ui/react';
interface AccountChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
name: string
ref?: Ref<HTMLButtonElement>
}
function AccountChip({ name, ref, style, ...props }: AccountChipProps) {
return (
<button
ref={ref}Error
Error: plain text instead of an element
Slot needs exactly one React element to clone. Plain text throws during render; the error boundary here catches it and shows the message, and the toggle switches back to a valid <a> child.
import { Component, type ReactNode, useState } from 'react';
import { Button, ButtonBase, HelperText } from '@vega-ui/react';
interface BoundaryProps {
children: ReactNode
}
interface BoundaryState {
message: string
}
class SlotErrorBoundary extends Component<BoundaryProps, BoundaryState> {Disabled
Disabled: blocked trigger that still explains itself
A natively disabled button gets no pointer or focus events, so a tooltip around it never opens. Through TooltipTrigger asChild the button uses aria-disabled instead, stays focusable, and the click handler checks the limit itself.
import { Button, Tooltip, TooltipArrow, TooltipContent, TooltipTrigger } from '@vega-ui/react';
import { useState } from 'react';
const limit = 3;
export default function SlottedDisabledButton() {
const [used, setUsed] = useState(3);
const blocked = used >= limit;
const createProject = () => {
if (blocked) return;
setUsed(used + 1);Edge
Edge: how props are merged
On a conflict the child wins for ordinary props such as title and for each style key. className is concatenated, id keeps the first defined value (the slot's), and both refs receive the node. The list is read from the rendered DOM.
import { useEffect, useRef, useState } from 'react';
import { Slot } from '@vega-ui/react';
export default function MergeRules() {
const slotRef = useRef<HTMLElement>(null);
const childRef = useRef<HTMLAnchorElement>(null);
const [result, setResult] = useState<string[]>([]);
useEffect(() => {
const node = childRef.current;
if (node === null) return;
Anatomy
Overview
Slot is a single-part low-level utility. It clones its only child and merges the passed props into that element.
Required Parts
Slot
Required. Owns the merge-and-clone behavior.
Optional Parts
There are no exported child parts.
Composition Order
Slot- one valid React element child
Valid Composition Patterns
import type { HTMLAttributes } from 'react';
import { Slot } from '@vega-ui/react';
interface TagProps extends HTMLAttributes<HTMLElement> {
asChild?: boolean
}
const tagStyle = {
display: 'inline-flex',
padding: 'var(--spacing-1) var(--spacing-5)',
border: 'none',
borderRadius: 999,Invalid Composition Patterns
More than one child
Children.only will reject the composition.
Non-element child
Plain text or invalid nodes cannot be cloned.