All components
Tags Input
Enter-to-add token input with removable item previews in outline, subtle, and flushed styles.
Teams
DesignEngineering
123456789101112131415161718192021222324252627import { TagsInput } from "#components/ui/base-ui/tags-input.tsx";
export function Example() { return ( // Base UI has no tags-input machine, so Root owns the tag list // (value/defaultValue/onValueChange); Enter adds, the delete trigger // removes, and parts compose through render <TagsInput.Root defaultValue={["Design", "Engineering"]} variant="outline" size="md"> <TagsInput.Label>Teams</TagsInput.Label> <TagsInput.Control> <TagsInput.Context> {(api) => api.value.map((tag) => ( <TagsInput.Item key={tag} value={tag}> <TagsInput.ItemPreview> <TagsInput.ItemText /> <TagsInput.ItemDeleteTrigger /> </TagsInput.ItemPreview> </TagsInput.Item> )) } </TagsInput.Context> <TagsInput.Input placeholder="Add team" aria-label="Add team" /> </TagsInput.Control> </TagsInput.Root> );}Variants
Teams
outlineDesignEngineering
Teams
subtleDesignEngineering
Teams
flushedDesignEngineering
Sizes
Teams
xsDesignEngineering
Teams
smDesignEngineering
Teams
mdDesignEngineering
Teams
lgDesignEngineering
Install
1npx stylexblocks add tags-inputSource
components/ui/base-ui/tags-input.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type SlotRecipeVariants,} from "#lib/recipe-props.ts";import { tagsInputItemDeleteTriggerIconVars, tagsInputClearTriggerIconVars,} from '@stylexblocks/ui/slot-recipes/tags-input.stylex.ts'
import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const tagsInputRoot = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._1_5, width: sizes.full, },})
const tagsInputRootSizes = stylex.create({ xs: { '--tags-input-height': sizes._8, '--tags-input-px': spacing._1_5, '--tags-input-py': spacing._1, '--tags-input-gap': spacing._1, '--tags-input-item-height': sizes._6, '--tags-input-item-px': spacing._2, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, }, sm: { '--tags-input-height': sizes._9, '--tags-input-px': spacing._1_5, '--tags-input-py': spacing._1, '--tags-input-gap': spacing._1, '--tags-input-item-height': sizes._6, '--tags-input-item-px': spacing._2, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, md: { '--tags-input-height': sizes._10, '--tags-input-px': spacing._1_5, '--tags-input-py': spacing._1, '--tags-input-gap': spacing._1, '--tags-input-item-height': sizes._7, '--tags-input-item-px': spacing._2, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { '--tags-input-height': sizes._10, '--tags-input-px': spacing._1_5, '--tags-input-py': spacing._1, '--tags-input-gap': spacing._1, '--tags-input-item-height': sizes._8, '--tags-input-item-px': spacing._2, fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const tagsInputLabel = stylex.create({ base: { fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const tagsInputControl = stylex.create({ base: { '--focus-color': colorPalette.focusRing, '--error-color': semanticColors.borderError, minHeight: 'var(--tags-input-height)', '--input-height': 'var(--tags-input-height)', paddingInline: 'var(--tags-input-px)', paddingBlock: 'var(--tags-input-py)', gap: 'var(--tags-input-gap)', display: 'flex', flexWrap: 'wrap', alignItems: 'center', borderRadius: semanticRadii.l2, position: 'relative', transitionProperty: 'border-color, box-shadow', transitionDuration: durations.moderate, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, },})
const tagsInputControlVariants = stylex.create({ outline: { borderWidth: '1px', backgroundColor: semanticColors.bg, outlineWidth: { default: null, ':focus': '1px', }, outlineStyle: { default: null, ':focus': 'solid', }, outlineColor: { default: null, ':focus': { default: 'var(--focus-color)', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':focus': { default: 'var(--focus-color)', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, }, }, subtle: { backgroundColor: semanticColors.bgMuted, borderWidth: '1px', borderColor: { default: 'transparent', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':focus': { default: 'var(--focus-color)', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, }, outlineWidth: { default: null, ':focus': '1px', }, outlineStyle: { default: null, ':focus': 'solid', }, outlineColor: { default: null, ':focus': { default: 'var(--focus-color)', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, }, }, flushed: { borderRadius: 0, paddingInline: 0, backgroundColor: 'transparent', borderBottomWidth: '1px', borderBottomColor: semanticColors.border, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':focus': 'var(--focus-color)', }, boxShadow: { default: null, ':focus': '0px 1px 0px 0px var(--focus-color)', }, },})
const tagsInputInput = stylex.create({ base: { flex: 1, minWidth: sizes._20, outline: 'none', backgroundColor: 'transparent', color: semanticColors.fg, paddingInline: 'calc(var(--tags-input-item-px) / 1.25)', height: 'var(--tags-input-item-height)', display: { default: null, ':is([data-readonly], [aria-readonly="true"], [readonly])': 'none', }, },})
const tagsInputItemText = stylex.create({ base: { lineClamp: 1, },})
const tagsInputItemInput = stylex.create({ base: { outline: 'none', backgroundColor: 'transparent', minWidth: '2ch', color: 'inherit', paddingInline: 'var(--tags-input-item-px)', height: 'var(--tags-input-item-height)', },})
const tagsInputItemPreview = stylex.create({ base: { height: 'var(--tags-input-item-height)', userSelect: 'none', display: 'inline-flex', alignItems: 'center', gap: spacing._1, borderRadius: semanticRadii.l1, paddingInline: 'var(--tags-input-item-px)', },})
const tagsInputItemPreviewVariants = stylex.create({ outline: { backgroundColor: colorPalette.subtle, }, subtle: { backgroundColor: semanticColors.bg, borderWidth: '1px', }, flushed: { backgroundColor: colorPalette.subtle, },})
const tagsInputItemDeleteTrigger = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 'calc(var(--tags-input-item-height) / 1.5)', height: 'calc(var(--tags-input-item-height) / 1.5)', marginInlineEnd: `calc(${spacing._1} * -1)`, opacity: { default: 0.4, ':hover': 1, }, [tagsInputItemDeleteTriggerIconVars.width]: '80%', [tagsInputItemDeleteTriggerIconVars.height]: '80%', },})
const tagsInputItemDeleteTriggerIcon = stylex.create({ base: { width: tagsInputItemDeleteTriggerIconVars.width, height: tagsInputItemDeleteTriggerIconVars.height, },})
const tagsInputClearTrigger = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 'calc(var(--tags-input-item-height) / 1.5)', height: 'calc(var(--tags-input-item-height) / 1.5)', color: semanticColors.fgMuted, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, focusRingWidth: '2px', borderRadius: semanticRadii.l1, [tagsInputClearTriggerIconVars.width]: sizes._5, [tagsInputClearTriggerIconVars.height]: sizes._5, },})
const tagsInputClearTriggerIcon = stylex.create({ base: { width: tagsInputClearTriggerIconVars.width, height: tagsInputClearTriggerIconVars.height, },})
export type TagsInputSize = keyof typeof tagsInputRootSizes
export type TagsInputVariant = keyof typeof tagsInputControlVariants
const tagsInputRecipe = defineSlotRecipe({ slots: { root: { styles: tagsInputRoot, sizes: tagsInputRootSizes }, label: { styles: tagsInputLabel }, control: { styles: tagsInputControl, variants: tagsInputControlVariants }, input: { styles: tagsInputInput }, itemText: { styles: tagsInputItemText }, itemInput: { styles: tagsInputItemInput }, itemPreview: { styles: tagsInputItemPreview, variants: tagsInputItemPreviewVariants }, itemDeleteTrigger: { styles: tagsInputItemDeleteTrigger }, clearTrigger: { styles: tagsInputClearTrigger }, }, defaultVariants: { size: "md", variant: "outline" },});
const chrome = stylex.create({ btnReset: { appearance: "none", backgroundColor: "transparent", borderWidth: 0, borderStyle: "none", padding: 0, margin: 0, fontFamily: "inherit", fontSize: "inherit", color: "inherit", cursor: "pointer", textAlign: "inherit", },});
function CloseIcon(props: React.ComponentProps<"svg">) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}
interface TagsInputContextValue { value: string[]; addValue: (tag: string) => void; removeValue: (tag: string) => void; clear: () => void;}
const TagsInputContext = createContext<TagsInputContextValue>({ value: [], addValue: () => {}, removeValue: () => {}, clear: () => {},});
const ItemContext = createContext<{ value: string }>({ value: "" });
const { withProvider, withContext } = createSlotRecipeContext(tagsInputRecipe);
export type TagsInputVariantProps = Partial<SlotRecipeVariants<typeof tagsInputRecipe>>;
function RootNode({ render, value, defaultValue = [], onValueChange, ...rest}: useRender.ComponentProps<"div"> & { value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void;}) { const [inner, setInner] = useState<string[]>(defaultValue); const current = value !== undefined ? value : inner; const ctx = useMemo<TagsInputContextValue>(() => { const commit = (next: string[]) => { setInner(next); onValueChange?.(next); }; return { value: current, addValue: (tag: string) => { const trimmed = tag.trim(); if (trimmed && !current.includes(trimmed)) commit([...current, trimmed]); }, removeValue: (tag: string) => commit(current.filter((t) => t !== tag)), clear: () => commit([]), }; }, [current, onValueChange]); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: rest, }); return <TagsInputContext.Provider value={ctx}>{element}</TagsInputContext.Provider>;}
function Item({ value, children }: { value: string; children?: React.ReactNode }) { const ctx = useMemo(() => ({ value }), [value]); return <ItemContext.Provider value={ctx}>{children}</ItemContext.Provider>;}
function ItemTextNode({ render, children, ...rest}: useRender.ComponentProps<"span">) { const { value } = useContext(ItemContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, props: mergeProps({ children: children ?? value }, rest), });}
function ItemDeleteTriggerNode({ render, children, onClick, ...rest}: useRender.ComponentProps<"button">) { const { removeValue } = useContext(TagsInputContext); const { value } = useContext(ItemContext); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps( { type: "button" as const, "aria-label": `Remove ${value}`, onClick: (event: React.MouseEvent<HTMLButtonElement>) => { (onClick as ((event: React.MouseEvent<HTMLButtonElement>) => void) | undefined)?.(event); removeValue(value); }, children: children ?? ( <CloseIcon {...stylex.props(tagsInputItemDeleteTriggerIcon.base)} /> ), }, rest, ), });}
function InputNode({ render, onChange, onKeyDown, ...rest}: Omit<useRender.ComponentProps<"input">, "value">) { const ctx = useContext(TagsInputContext); const [draft, setDraft] = useState(""); return useRender({ defaultTagName: "input", render: render as useRender.RenderProp, props: mergeProps( { value: draft, onChange: (event: React.ChangeEvent<HTMLInputElement>) => { (onChange as ((event: React.ChangeEvent<HTMLInputElement>) => void) | undefined)?.(event); setDraft(event.target.value); }, onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => { (onKeyDown as ((event: React.KeyboardEvent<HTMLInputElement>) => void) | undefined)?.( event, ); if (event.key === "Enter" && draft.trim()) { event.preventDefault(); ctx.addValue(draft); setDraft(""); } else if (event.key === "Backspace" && draft === "" && ctx.value.length > 0) { ctx.removeValue(ctx.value[ctx.value.length - 1]!); } }, }, rest, ), });}
function ClearTriggerNode({ render, children, onClick, ...rest}: useRender.ComponentProps<"button">) { const { clear } = useContext(TagsInputContext); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps( { type: "button" as const, "aria-label": "Clear tags", onClick: (event: React.MouseEvent<HTMLButtonElement>) => { (onClick as ((event: React.MouseEvent<HTMLButtonElement>) => void) | undefined)?.(event); clear(); }, children: children ?? <CloseIcon {...stylex.props(tagsInputClearTriggerIcon.base)} />, }, rest, ), });}
function Context({ children,}: { children: (ctx: TagsInputContextValue) => React.ReactNode;}) { return children(useContext(TagsInputContext));}
/** * TagsInput — Enter-to-add token input. Base UI ships no tags-input machine, * so Root owns the tag list (controlled via `value`/`onValueChange` or * uncontrolled via `defaultValue`). `<TagsInput.Root variant size>` resolves * the recipe once; every part inherits it through context. Composition per * part via `render`. */export const TagsInput = { Root: withProvider(RootNode, "root", "TagsInputRoot"), Label: withContext("span", "label", "TagsInputLabel"), Control: withContext("div", "control", "TagsInputControl"), Item, ItemPreview: withContext("span", "itemPreview", "TagsInputItemPreview"), ItemText: withContext(ItemTextNode, "itemText", "TagsInputItemText"), ItemDeleteTrigger: withContext( ItemDeleteTriggerNode, (v) => [chrome.btnReset, ...resolveSlotStyles(tagsInputRecipe.slots.itemDeleteTrigger, v)], "TagsInputItemDeleteTrigger", ), Input: withContext(InputNode, "input", "TagsInputInput"), ClearTrigger: withContext( ClearTriggerNode, (v) => [chrome.btnReset, ...resolveSlotStyles(tagsInputRecipe.slots.clearTrigger, v)], "TagsInputClearTrigger", ), Context,};
export type TagsInputRootProps = React.ComponentProps<typeof TagsInput.Root>;