Getting comfortable building my own shadcn components
·3 min read

Getting comfortable building my own shadcn components

Building a controlled tag input helped me get comfortable changing the component source instead of waiting for the exact widget I needed.

I'd got used to adding a shadcn component whenever I needed a piece of UI. It was convenient enough that I rarely stopped to ask how much of it I wanted to change. Then I needed a tag input for a form, and the ready-made pieces didn't quite match the interaction I had in mind.

Building it was a useful way to get past that habit. I could reuse the search and menu behaviour, keep the styling consistent with the app, and still choose how the selected tags behaved.

Starting from the form

The selected tags needed to live in the parent form. I was using react-hook-form, and I wanted validation, submission and resetting the form to work with the same values. The component could own temporary details such as the search query and whether the menu was open.

TypeScript
type Tag<T> = { label: string; value: T }
interface TagInputProps<T> {
tags: Tag<T>[];
setTags: (tags: Tag<T>[]) => void;
allTags: Tag<T>[];
AllTagsLabel?: ({ value }: { value: T }) => React.ReactNode;
placeholder?: string;
className?: string;
}

The label is what the user reads. The value is what the application stores or acts on. Keeping those separate lets the same component work with IDs or richer values without turning the display text into the whole data model.

Reusing the command menu

The Command components gave me a searchable list to build on. I put the selected tags into pills beside the input and used the input text to filter the remaining options.

TypeScript
<Command className={cn("rounded", className)}>
<div className={cn("flex w-full items-center flex-wrap gap-2 border")}>
{/* This is where our selected tags live */}
{tags.map((tag) => (
<Pill key={tag.label} label={tag.label} onClick={() => handleRemove(tag)} />
))}
{/* Our input field and clear button */}
<div className="flex flex-grow items-center justify-end">
<div className="flex-1 min-w-0">
<CommandInput
placeholder={placeholder}
value={inputValue}
onValueChange={handleValueChange}
onKeyDown={handleBackSpace}
className="h-2 w-full"
ref={commandInput}
/>
</div>
</div>
</div>
</Command>

This is an excerpt of the layout, so the handlers and Pill component are defined elsewhere. The important part is where the values come from: the pills render the parent's tags, while typing changes the component's local query.

TypeScript
// Maintain dropdown open/close state
const [open, setOpen] = React.useState(false);
// Maintain filter state, important for filtering later
const [inputValue, setInputValue] = React.useState("");
// Filter tags based on input
const filteredTags = React.useMemo(
() =>
allTags.filter(
(tag) =>
tag.label.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.some((selectedTag) => selectedTag.label === tag.label),
),
[allTags, inputValue, tags],
);

The filter also excludes selected labels. That matches this component's assumption that labels are unique. If two options can have the same label, their identity needs to use something else, and the interface needs a way to tell them apart.

Changing the input wrapper

I wanted more control over the input's layout and keyboard handling, so I wrote a local wrapper around CommandPrimitive.Input. That primitive comes from cmdk; it isn't a Radix input.

TypeScript
// Instead of using Shadcn's CommandInput, we created our own using the primitive.
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center px-3" cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none",
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = "CommandInput";

The ref and input props pass through to the primitive. The wrapper owns the styling. That was enough flexibility for what I needed without replacing the whole command menu.

What I took from it

I became more comfortable reading the component source and changing the part that didn't fit. Reusing a component doesn't mean I have to keep every choice it arrived with, and customising it doesn't mean starting from an empty file.

There is some responsibility in that freedom. Once the source is in my app, I need to understand and test the behaviour I change. For this input, I'd check keyboard selection, removal, duplicate handling and form resets along with how it looks. Getting the demo to render is a useful first step, but it's only part of making it a component I'd want to reuse.