shadcn-ui-expansions: the missing components for shadcn/ui

January 19, 2025

|repo-review

by Florian Narr

shadcn-ui-expansions: the missing components for shadcn/ui

shadcn-ui-expansions fills the gaps in shadcn/ui by adding 14+ components that follow the exact same copy-paste philosophy — no npm install, just take the source and make it yours.

Why I starred it

shadcn/ui is deliberately minimal. It ships primitives and lets you build upward. But every project that uses it eventually needs the same handful of things: a multi-select with async search, a datetime picker that doesn't require three dependencies, a textarea that grows as you type. You end up rebuilding these from scratch or pulling in a heavy library.

This repo is the answer to that specific frustration. The components aren't wrapped packages — they're just files in components/ui/, mirroring shadcn's own structure, so they slot in without any conceptual overhead.

How it works

I opened components/ui/multiple-selector.tsx first because multi-select is where most implementations fall apart. This one is 500+ lines and worth reading closely.

The component builds on cmdk rather than reinventing keyboard navigation. What it adds is a grouping layer — transToGroupOption() buckons arbitrary option arrays into a Record<string, Option[]> shape keyed by any field you specify:

function transToGroupOption(options: Option[], groupBy?: string) {
  if (!groupBy) return { '': options };
  const groupOption: GroupOption = {};
  options.forEach((option) => {
    const key = (option[groupBy] as string) || '';
    if (!groupOption[key]) groupOption[key] = [];
    groupOption[key].push(option);
  });
  return groupOption;
}

That same structure feeds removePickedOption(), which filters already-selected values out of the dropdown without mutating the original list. The deduplication is clean: a JSON.parse(JSON.stringify(...)) deep clone to avoid reference issues, then a linear scan. Not optimal at large scale, but correct and readable.

The async search path uses a bundled useDebounce hook with a configurable delay prop. There's also a triggerSearchOnFocus flag for pre-loading options on input focus — useful for UIs where the option list is small enough to fetch upfront.

One fix worth noting: the comment on CommandEmpty explains that shadcn's own CommandEmpty breaks cmdk's empty-state rendering in certain contexts, so the component reimplements it from scratch, copying the upstream cmdk implementation directly. That kind of pragmatic patch-over-the-abstraction is a sign someone actually debugged this in production.

InfiniteScroll (components/ui/infinite-scroll.tsx) is the most interesting architectural piece. Rather than wrapping a scroll container, it uses a callback ref that attaches an IntersectionObserver to the last child element. When the observed element enters the viewport, next() fires.

const observerRef = React.useCallback(
  (element: HTMLElement | null) => {
    if (isLoading) return;
    if (observer.current) observer.current.disconnect();
    if (!element) return;
    observer.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore) next();
      },
      { threshold: safeThreshold, root, rootMargin },
    );
    observer.current.observe(element);
  },
  [hasMore, isLoading, next, threshold, root, rootMargin],
);

The callback ref approach means it reconnects automatically when hasMore or next changes — no stale closure problem. The reverse prop flips the observer target to the first child instead of the last, for chat-style UIs that load older messages at the top.

AutosizeTextarea (components/ui/autosize-textarea.tsx) solves the classic textarea height problem with a two-step: reset height to minHeight, read scrollHeight, then set height to that value. It does this outside the React render loop to avoid layout thrash:

textAreaElement.style.height = `${minHeight + offsetBorder}px`;
const scrollHeight = textAreaElement.scrollHeight;
if (scrollHeight > maxHeight) {
  textAreaElement.style.height = `${maxHeight}px`;
} else {
  textAreaElement.style.height = `${scrollHeight + offsetBorder}px`;
}

The offsetBorder constant of 6 accounts for border width without requiring box-sizing assumptions — a small detail that prevents a common clipping bug.

The DatetimePicker leans on react-day-picker for the calendar and implements its own time input with typed numeric fields and wraparound logic using a loop flag in getValidNumber(). Hours, minutes, and seconds each have their own validation regex and clamp functions. It supports both 12h and 24h formats and exposes locale via date-fns/locale.

Using it

The same way you use shadcn/ui — copy the file into your project:

# copy the component you want
cp components/ui/multiple-selector.tsx your-project/components/ui/

Then use it:

import MultipleSelector, { Option } from '@/components/ui/multiple-selector';

const OPTIONS: Option[] = [
  { label: 'React', value: 'react' },
  { label: 'Vue', value: 'vue' },
  { label: 'Svelte', value: 'svelte' },
];

<MultipleSelector
  defaultOptions={OPTIONS}
  placeholder="Select frameworks..."
  groupBy="category"
  creatable
  onChange={(selected)=> console.log(selected)}
/>

The async search path takes an onSearch prop that returns Promise<Option[]>:

<MultipleSelector
  onSearch={async (query)=> {
    const res= await fetch(`/api/search?=${query}`);
    return res.json();
  }}
  delay={300}
  triggerSearchOnFocus
/>

Rough edges

No tests anywhere. The component logic is solid enough that this isn't an immediate problem, but it does mean there's no safety net for regressions on contributions.

The repo recently moved its demo site from Vercel to Netlify (March 2026 commit). The package.json still lists openai as a dependency — leftover from an AI demo that no longer appears in the main component list. Minor clutter.

The DatetimePicker is the most complex component and the one most likely to have edge cases around timezone handling. It stores a plain Date object without any timezone normalization, so what you get out is what the user's local clock produces.

There's also no CLI tooling. shadcn/ui has npx shadcn add [component] — this repo requires manual file copying, which is consistent with the philosophy but adds friction for teams that want to track component versions.

Bottom line

If you're already using shadcn/ui and you keep writing the same textarea resize logic or multi-select from scratch, this is the repo to bookmark. The MultipleSelector alone justifies the star — the async search path with debouncing and group support is production-ready.

hsuanyi-chou/shadcn-ui-expansions on GitHub
hsuanyi-chou/shadcn-ui-expansions