gentleduck/ui vs shadcn/ui
Same copy-paste philosophy. gentleduck/ui gives you the primitives, calendar, keyboard engine, and motion system that shadcn depends on external libraries for.
Quick comparison
| gentleduck/ui | shadcn/ui | |
|---|---|---|
| Styled components | 57 | 50+ |
| Primitives | Own (@gentleduck/primitives, 35 components) | Radix UI (external dependency) |
| Blocks | 6 categories (auth, dashboard, calendar, charts, blog, sidebar) | 6 categories |
| Calendar | Own engine (@gentleduck/calendar, ~5 KB, 0 deps) | react-day-picker (~20 KB + date-fns) |
| Variant system | @gentleduck/variants (cva with memoization) | class-variance-authority |
| Motion / Animation | @gentleduck/motion (tokens + reduced-motion) | None |
| Keyboard engine | @gentleduck/vim (bindings, sequences, recorder) | None |
| State management | @gentleduck/state (atom-based) | None |
| CLI commands | init, add, diff, update, remove, list | init, add, diff |
| Source ownership | Full stack (primitives + components) | Components only (primitives are in node_modules) |
| Styling | Tailwind + cn() + data-* state selectors | Tailwind + cn() |
| TypeScript | Full type safety | Full type safety |
| React | 18+ | 18+ |
Same foundation, more depth
Both projects share the same core idea: components are copied into your project, not installed as dependencies. You own the code. You can modify anything. The CLI scaffolds components the same way.
# shadcn
npx shadcn@latest add button
# gentleduck
npx gentleduck add button# shadcn
npx shadcn@latest add button
# gentleduck
npx gentleduck add buttonBoth use Tailwind for styling, cn() for class merging, CSS variables for theming, and the Root / Trigger / Content compound pattern for complex components. The JSX is nearly identical. If you know shadcn, you already know gentleduck.
The difference is what happens underneath.
What shadcn depends on, gentleduck owns
Primitives
shadcn/ui imports Radix UI from node_modules. You own the styled layer, but the accessibility, focus management, dismiss behavior, and animation logic live in packages you cannot modify. When Radix ships a breaking change or deprecates an API, your components break and you wait for a fix.
gentleduck/ui uses @gentleduck/primitives - 35 headless components with the same compound-component API as Radix, but 50-92% smaller because shared internals (Slot, Presence, Popper, focus scope) are deduplicated across all primitives instead of bundled per-package. You own the source. Modify focus trapping, dismiss behavior, or animation hooks directly.
See the full primitives comparison.
Calendar
shadcn/ui wraps react-day-picker, which pulls in date-fns. That is ~20 KB gzipped for a single calendar system (Gregorian) with no adapter pattern.
gentleduck/ui uses @gentleduck/calendar - ~5 KB gzipped with zero required dependencies. The adapter pattern lets you use native Date, date-fns, dayjs, or luxon without changing your components. Non-Gregorian calendars (Islamic, Persian, Hebrew) work through pluggable adapters. No other copy-paste component library ships this.
See the full calendar comparison.
Keyboard engine
@gentleduck/vim provides key bindings, multi-key sequences (vim-style), macro recording, and platform-aware formatting (Cmd on Mac, Ctrl elsewhere). shadcn/ui has no keyboard abstraction - you wire up event listeners manually.
import { useKeymap } from '@gentleduck/vim/react'
useKeymap({
'Mod+k': () => openCommandPalette(),
'Mod+s': () => save(),
'g g': () => scrollToTop(),
})import { useKeymap } from '@gentleduck/vim/react'
useKeymap({
'Mod+k': () => openCommandPalette(),
'Mod+s': () => save(),
'g g': () => scrollToTop(),
})Animation tokens
@gentleduck/motion provides prebuilt animation keyframes and a reduced-motion system that integrates with the Presence primitive. Animations respect prefers-reduced-motion automatically. shadcn/ui leaves animation to you.
State management
@gentleduck/state provides an atom-based state management system for component-level and app-level state. shadcn/ui does not include state tooling.
CLI
| Feature | gentleduck | shadcn |
|---|---|---|
init | Yes | Yes |
add | Yes | Yes |
diff (view changes) | Yes, with interactive TUI | Yes |
list (installed components) | Yes | No |
update (pull latest + merge) | Yes, with interactive merge TUI | No |
remove (clean uninstall) | Yes | No |
| Template scaffolding | --template flag for full project scaffold | No |
| Monorepo support | --monorepo flag, workspace-aware config | No |
| Interactive merge TUI | Hunk-by-hunk resolution with vim keys | No |
| Syntax-highlighted diffs | Side-by-side terminal view | No |
The update command is the standout. When you update a component, it detects your local modifications and launches an interactive merge screen in your terminal. You see each change as a hunk and choose per-hunk: keep your version (1), take the registry version (2), or keep both (3). Navigate with vim keys (h/l between files, j/k between hunks), preview the merged result before writing, and confirm with Enter. No manual conflict resolution. No git merge markers.
remove cleanly deletes a component and its unused dependencies. list shows every available component with its install status. diff shows what changed between your local copy and the latest registry version in a syntax-highlighted side-by-side TUI.
shadcn ecosystem compatibility
gentleduck/ui is structurally compatible with the shadcn ecosystem. Same component names, same compound patterns, same Tailwind approach, same CSS variable theming. Any shadcn template, block, or third-party integration works with gentleduck - you swap the base component imports and everything else stays the same.
This means you do not lose access to the shadcn ecosystem by switching. You gain a deeper stack underneath it.
Same components, more features
Every gentleduck component is a drop-in replacement for its shadcn equivalent. Same JSX, same props, same Tailwind classes. The difference is what you get for free.
Button
shadcn ships a basic button with variant and size props. gentleduck adds:
| Feature | gentleduck | shadcn |
|---|---|---|
| Loading state with spinner | loading prop, auto-disables | Manual implementation |
| Icon placement | icon and secondIcon props | Manual children |
| Collapsed mode | isCollapsed for sidebar toggles | Manual implementation |
| Animated icon | AnimationIcon with slide/fade on hover | Not available |
| Border variants | border prop (primary, destructive, warning) | Not available |
| Dashed variant | variant="dashed" | Not available |
| Extra sizes | icon-lg, icon-sm | Not available |
// shadcn: you build loading state yourself
<Button disabled={isLoading}>
{isLoading ? <Loader className="animate-spin" /> : null}
{isLoading ? 'Saving...' : 'Save'}
</Button>
// gentleduck: one prop
<Button loading={isLoading} icon={<Save />}>Save</Button>// shadcn: you build loading state yourself
<Button disabled={isLoading}>
{isLoading ? <Loader className="animate-spin" /> : null}
{isLoading ? 'Saving...' : 'Save'}
</Button>
// gentleduck: one prop
<Button loading={isLoading} icon={<Save />}>Save</Button>Dialog
shadcn ships a basic dialog. gentleduck adds a DialogResponsive variant that automatically switches between Dialog on desktop and Drawer on mobile at 768px. No media query code. No conditional rendering. One component.
// shadcn: you build responsive behavior yourself
const isDesktop = useMediaQuery('(min-width: 768px)')
return isDesktop ? <Dialog>...</Dialog> : <Drawer>...</Drawer>
// gentleduck: built-in
<DialogResponsive>
<DialogTriggerResponsive>Open</DialogTriggerResponsive>
<DialogContentResponsive>
{/* Same content, renders as Dialog or Drawer automatically */}
</DialogContentResponsive>
</DialogResponsive>// shadcn: you build responsive behavior yourself
const isDesktop = useMediaQuery('(min-width: 768px)')
return isDesktop ? <Dialog>...</Dialog> : <Drawer>...</Drawer>
// gentleduck: built-in
<DialogResponsive>
<DialogTriggerResponsive>Open</DialogTriggerResponsive>
<DialogContentResponsive>
{/* Same content, renders as Dialog or Drawer automatically */}
</DialogContentResponsive>
</DialogResponsive>Calendar
shadcn wraps react-day-picker (~20 KB + date-fns). gentleduck ships a full calendar system at ~5 KB with zero dependencies:
| Feature | gentleduck | shadcn |
|---|---|---|
| Bundle size | ~5 KB gzipped | ~20 KB + date-fns |
| Calendar systems | 4 (Gregorian, Islamic, Persian, Hebrew) | 1 (Gregorian) |
| Date adapters | 7 (Native, date-fns, dayjs, luxon, + 3 calendar-specific) | date-fns only |
| Selection modes | Single, range, multi | Single, range, multi |
| Render props | renderDay, renderHeader, renderWeekday, renderFooter | Limited |
| Month/year dropdowns | showDropdowns prop | Manual implementation |
| Multiple months | numberOfMonths prop | numberOfMonths prop |
| Locale/RTL | Full i18n with Arabic numerals, Persian text | Basic locale |
Combobox
shadcn documents a combobox pattern using Command + Popover manually. gentleduck ships a dedicated Combobox component:
| Feature | gentleduck | shadcn |
|---|---|---|
| Single + multi select | Generic type for both modes | Manual implementation |
| Search filtering | withSearch prop | Manual Command integration |
| Selected item display | Badges with "+N Selected" truncation | Manual implementation |
| RTL support | Built-in dir prop | Not available |
InputGroup and ButtonGroup
These components do not exist in shadcn at all.
InputGroup provides addon slots (icons, buttons, labels) that attach to inputs with proper focus delegation - clicking the addon focuses the input. Supports inline-start, inline-end, block-start, block-end positions. Error states cascade from input to the group wrapper automatically.
ButtonGroup provides horizontal/vertical grouping with separators and text labels, with full RTL support.
Tooltip
gentleduck's tooltip supports disableCloseOnClick for wrapping interactive elements like toggles. In shadcn, putting a tooltip on a toggle button causes the tooltip to intercept the click and close instead of toggling. gentleduck solves this at the primitive level.
Sidebar
Both ship a sidebar component. gentleduck adds:
| Feature | gentleduck | shadcn |
|---|---|---|
| Collapse modes | offcanvas, icon, none | offcanvas, icon, none |
| Cookie persistence | Auto-saves state to cookies | Auto-saves state to cookies |
| Keyboard shortcut | Cmd/Ctrl+B toggles sidebar | Cmd/Ctrl+B toggles sidebar |
| RTL support | Built-in side + dir props | side prop |
| Mobile sheet | Auto-switches to Sheet on mobile | Auto-switches to Sheet on mobile |
The sidebar implementations are nearly identical. This is an example of where shadcn got it right and gentleduck matches the behavior while running on lighter primitives underneath.
Code comparison
The same Button component in both:
shadcn/ui
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Slot } from '@radix-ui/react-slot'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ...',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Slot } from '@radix-ui/react-slot'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ...',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
},
)gentleduck/ui
import { cva } from '@gentleduck/variants'
import { cn } from '@gentleduck/libs/cn'
import { Slot, Slottable } from '@gentleduck/primitives/slot'
const buttonVariants = cva(
'relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 ...',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
outline: 'border border-input bg-background shadow-xs hover:bg-accent',
destructive: 'bg-destructive/90 text-destructive-foreground shadow-xs hover:bg-destructive/70',
ghost: 'text-accent-foreground hover:bg-accent',
link: 'text-primary underline-offset-4 hover:underline',
dashed: 'border border-input border-dashed bg-background shadow-xs hover:bg-accent/50',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 px-6 has-[>svg]:px-4',
icon: 'size-9',
},
border: {
default: '',
primary: 'border border-border/40 hover:border-border/80',
destructive: 'border border-destructive/40 bg-destructive/40 hover:border-destructive',
},
},
defaultVariants: { variant: 'default', size: 'default', border: 'default' },
},
)
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, variant, size, border, asChild, className, loading, icon, ...props }, ref) => {
const Comp = (asChild ? Slot : 'button') as React.ElementType
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ border, variant, size, className }))}
disabled={loading || props.disabled}
ref={ref}
{...props}>
{loading ? <Loader className="animate-spin" /> : icon}
<Slottable>{children}</Slottable>
</Comp>
)
},
)
Button.displayName = 'Button'import { cva } from '@gentleduck/variants'
import { cn } from '@gentleduck/libs/cn'
import { Slot, Slottable } from '@gentleduck/primitives/slot'
const buttonVariants = cva(
'relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 ...',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
outline: 'border border-input bg-background shadow-xs hover:bg-accent',
destructive: 'bg-destructive/90 text-destructive-foreground shadow-xs hover:bg-destructive/70',
ghost: 'text-accent-foreground hover:bg-accent',
link: 'text-primary underline-offset-4 hover:underline',
dashed: 'border border-input border-dashed bg-background shadow-xs hover:bg-accent/50',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 px-6 has-[>svg]:px-4',
icon: 'size-9',
},
border: {
default: '',
primary: 'border border-border/40 hover:border-border/80',
destructive: 'border border-destructive/40 bg-destructive/40 hover:border-destructive',
},
},
defaultVariants: { variant: 'default', size: 'default', border: 'default' },
},
)
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, variant, size, border, asChild, className, loading, icon, ...props }, ref) => {
const Comp = (asChild ? Slot : 'button') as React.ElementType
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ border, variant, size, className }))}
disabled={loading || props.disabled}
ref={ref}
{...props}>
{loading ? <Loader className="animate-spin" /> : icon}
<Slottable>{children}</Slottable>
</Comp>
)
},
)
Button.displayName = 'Button'The gentleduck Button ships with more: a loading state with spinner, icon prop, border variant dimension, dashed and warning variants, and Slottable for proper asChild content projection. The cva API is the same but @gentleduck/variants adds memoization - repeated calls with the same props return a cached result. The data-slot attribute enables parent-scoped styling without fragile descendant selectors.
Migration
Migration is incremental. Both component sets coexist in the same project. Replace one at a time.
1. Install and initialize
npm install -D @gentleduck/cli npx gentleduck init
npm install -D @gentleduck/cli npx gentleduck init
2. Replace components one by one
npx gentleduck add button npx gentleduck add dialog npx gentleduck add calendar
npx gentleduck add button npx gentleduck add dialog npx gentleduck add calendar
Each gentleduck component drops in where the shadcn equivalent was. Same props, same patterns, same Tailwind classes.
3. Remove Radix dependencies as they become unused
npm uninstall @radix-ui/react-dialog @radix-ui/react-popovernpm uninstall @radix-ui/react-dialog @radix-ui/react-popover4. Swap cva import
- import { cva } from 'class-variance-authority'
+ import { cva } from '@gentleduck/variants'- import { cva } from 'class-variance-authority'
+ import { cva } from '@gentleduck/variants'Same API. No code changes needed beyond the import path.
Why teams switch
A smaller bundle, immediately. The calendar drops from ~20 KB to ~5 KB. Each primitive is 50-92% lighter than its Radix equivalent. The variant system adds memoization. These land the moment you swap a component, with no refactoring.
The full stack is yours. With shadcn, you own the styled layer but depend on Radix for behavior. With gentleduck, you own primitives, calendar, keyboard engine, motion tokens, and state management. When something breaks, you fix it in your repo instead of filing an issue and waiting.
You get more in the box. A keyboard command system, animation tokens with automatic reduced-motion support, an atom-based state manager, and a CLI that can update, remove, and list components - not just add them.
You keep the shadcn ecosystem. Templates, blocks, and third-party integrations built for shadcn work with gentleduck because the component structure is the same. Switching does not mean starting over.