Skip to main content

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/uishadcn/ui
Styled components5750+
PrimitivesOwn (@gentleduck/primitives, 35 components)Radix UI (external dependency)
Blocks6 categories (auth, dashboard, calendar, charts, blog, sidebar)6 categories
CalendarOwn 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 commandsinit, add, diff, update, remove, listinit, add, diff
Source ownershipFull stack (primitives + components)Components only (primitives are in node_modules)
StylingTailwind + cn() + data-* state selectorsTailwind + cn()
TypeScriptFull type safetyFull type safety
React18+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 button

Both 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

Featuregentleduckshadcn
initYesYes
addYesYes
diff (view changes)Yes, with interactive TUIYes
list (installed components)YesNo
update (pull latest + merge)Yes, with interactive merge TUINo
remove (clean uninstall)YesNo
Template scaffolding--template flag for full project scaffoldNo
Monorepo support--monorepo flag, workspace-aware configNo
Interactive merge TUIHunk-by-hunk resolution with vim keysNo
Syntax-highlighted diffsSide-by-side terminal viewNo

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:

Featuregentleduckshadcn
Loading state with spinnerloading prop, auto-disablesManual implementation
Icon placementicon and secondIcon propsManual children
Collapsed modeisCollapsed for sidebar togglesManual implementation
Animated iconAnimationIcon with slide/fade on hoverNot available
Border variantsborder prop (primary, destructive, warning)Not available
Dashed variantvariant="dashed"Not available
Extra sizesicon-lg, icon-smNot 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:

Featuregentleduckshadcn
Bundle size~5 KB gzipped~20 KB + date-fns
Calendar systems4 (Gregorian, Islamic, Persian, Hebrew)1 (Gregorian)
Date adapters7 (Native, date-fns, dayjs, luxon, + 3 calendar-specific)date-fns only
Selection modesSingle, range, multiSingle, range, multi
Render propsrenderDay, renderHeader, renderWeekday, renderFooterLimited
Month/year dropdownsshowDropdowns propManual implementation
Multiple monthsnumberOfMonths propnumberOfMonths prop
Locale/RTLFull i18n with Arabic numerals, Persian textBasic locale

Combobox

shadcn documents a combobox pattern using Command + Popover manually. gentleduck ships a dedicated Combobox component:

Featuregentleduckshadcn
Single + multi selectGeneric type for both modesManual implementation
Search filteringwithSearch propManual Command integration
Selected item displayBadges with "+N Selected" truncationManual implementation
RTL supportBuilt-in dir propNot 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.

Both ship a sidebar component. gentleduck adds:

Featuregentleduckshadcn
Collapse modesoffcanvas, icon, noneoffcanvas, icon, none
Cookie persistenceAuto-saves state to cookiesAuto-saves state to cookies
Keyboard shortcutCmd/Ctrl+B toggles sidebarCmd/Ctrl+B toggles sidebar
RTL supportBuilt-in side + dir propsside prop
Mobile sheetAuto-switches to Sheet on mobileAuto-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-popover
npm uninstall @radix-ui/react-dialog @radix-ui/react-popover

4. 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.