Introduction
Well, you guessed it. Basically I am accustomed to doing npx shadcn@latest add some-magic-component
, over and over I have framed myself on relying on these pre built components too much without wondering on, what if I made my own.
So, I made my own, and decided to share some of the stuff that I learnt.
Approaching the idea of writing my own
You have to know that my requirements and shad's requirements might not see eye to eye most of the time. The user interaction, look and feel everything is acceptable to change within the scope of requirement, because Stacy from HR wants to have colors, well guess what Stacy, we don't have them. What I am trying to say is I don't want to build components surrounding ShadCn, instead I plan on using the abstractions provided when necessary and doing my own tinkering to make a specific component work my way instead of his way. Cool right?
Understanding my use case: Tag Input
Well, you guessed it. Shadcn doesn't have a tag input. So instead of screaming out loud like a kid in the github issues section, asking for a tag input, I decided to make my own.
This is what I came up with.
1interface TagInputProps<T> {
2 tags: Tag<T>[];
3 setTags: (tags: Tag<T>[]) => void;
4 allTags: Tag<T>[];
5 AllTagsLabel?: ({ value }: { value: T }) => React.ReactNode;
6 placeholder?: string;
7 className?: string;
8}
I wanted a reusable, controlled component, where its state was being maintained externally, while uncontrolled, internal state was being maintained inside the component. Controlled compoents come in handy where you can easily integrate its state with a higher order component, in my case this was a part of a form controlled using react-hook-form
.
Designing Tag Input
Even though I got my principles correct, there was another challenge, specially when it came to handling the internal state inside the component. This is where shadcn and radix primitves comes into place. Shadcn offers a command component, that contains a searchable dropdown, that fulfills my requirements for searching. Therefore I decided to use the Command component from shadcn.
1<Command className={cn("rounded", className)}>
2 <div className={cn("flex w-full items-center flex-wrap gap-2 border rounded-md p-2")}>
3 {/* This is where our selected tags live */}
4 {tags.map((tag) => (
5 <Pill
6 key={tag.label}
7 label={tag.label}
8 onClick={() => handleRemove(tag)}
9 />
10 ))}
11
12 {/* Our input field and clear button */}
13 <div className="flex flex-grow items-center justify-end">
14 <div className="flex-1 min-w-0">
15 <CommandInput
16 placeholder={placeholder}
17 value={inputValue}
18 onValueChange={handleValueChange}
19 onKeyDown={handleBackSpace}
20 className="h-2 w-full"
21 ref={commandInput}
22 />
23 </div>
24 <X
25 className="w-4 h-4 cursor-pointer hover:text-red-700 transition-colors ml-2"
26 onClick={handleClear}
27 />
28 </div>
29 </div>
30
31 {/* Our dropdown for tag selection */}
32 {open && (
33 <div className="w-full">
34 <CommandList className="hide-scrollbar bg-primary-foreground w-full rounded-md">
35 <CommandEmpty>No tags found.</CommandEmpty>
36 <CommandGroup>
37 {filteredTags.map((tag) => (
38 <CommandItem
39 key={tag.label}
40 value={tag.label}
41 onSelect={() => handleSelect(tag)}
42 className="hover:cursor-pointer"
43 >
44 {AllTagsLabel ? <AllTagsLabel value={tag.value} /> : tag.label}
45 </CommandItem>
46 ))}
47 </CommandGroup>
48 </CommandList>
49 </div>
50 )}
51</Command>
But command does not support external state control. I had to come up with my own, hence filteredTags. However the CommandItem wrapper clearly registers the value in the context of the command component so I didn't have to worry about those abstractions.
We maintained our own state here.
1// Maintain dropdown open/close state
2const [open, setOpen] = React.useState(false);
3// Maintain filter state, important for filtering later
4const [inputValue, setInputValue] = React.useState("");
5
6// Filter tags based on input
7const filteredTags = React.useMemo(() =>
8 allTags.filter(
9 (tag) =>
10 tag.label.toLowerCase().includes(inputValue.toLowerCase()) &&
11 !tags.some((selectedTag) => selectedTag.label === tag.label)
12 ),
13 [allTags, inputValue, tags]
14);
This way we could re use a lot of the stuff shadcn provided but, have our own control when needed.
Balance, at last. We used ShadCn when we could and Radix when we couldn't.
Having more control with Radix Primitives. Control over abstractions.
Shad is great. But what if I want something he doesn't with his component. Well this is my component now, so I'll just override it. And so I did with commandInput.
1// Instead of using shadcn's CommandInput, we created our own using the Radix primitive
2const CommandInput = React.forwardRef
3 React.ElementRef<typeof CommandPrimitive.Input>,
4 React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
5>(({ className, ...props }, ref) => (
6 <div className="flex items-center px-3" cmdk-input-wrapper="">
7 <CommandPrimitive.Input
8 ref={ref} // we just passed a ref here
9 className={cn(
10 "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none",
11 "placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
12 className
13 )}
14 {...props}
15 />
16 </div>
17));
18
19CommandInput.displayName = "CommandInput";
Well, here we needed to control the state of the CommandInput more aggressively because we needed to listen to different interactions like keystrokes, or value changes. This flexibility isnt directly offered with shadcn at some times, so you can actually use the radix primitives instead. Whatever that works for us the best, amiright?