This guide covers setting up Shadcn/ui with TanStack Router, including solutions for common animation and compatibility issues.
Time Required: 30-45 minutes
Difficulty: Intermediate
Prerequisites: Existing TanStack Router project
Option 1: New project with TanStack Router template
npx create-tsrouter-app@latest my-app --template file-router --tailwind --add-ons shadcn
Option 2: Add to existing TanStack Router project
npx shadcn@latest init
Create or update your components.json for TanStack Router compatibility:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
Install the most commonly used components:
npx shadcn@latest add button
npx shadcn@latest add navigation-menu
npx shadcn@latest add sheet
npx shadcn@latest add dialog
Update your root route to support portals and animations:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
export const Route = createRootRoute({
component: () => (
<>
{/* Main content wrapper */}
<div id="root-content">
<Outlet />
</div>
{/* Portal root for overlays */}
<div id="portal-root"></div>
<TanStackRouterDevtools />
</>
),
})
Shadcn/ui Sheet components can have animation issues. Create a wrapper:
// src/components/ui/router-sheet.tsx
import * as React from 'react'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
interface RouterSheetProps {
children: React.ReactNode
trigger: React.ReactNode
title: string
description?: string
onOpenChange?: (open: boolean) => void
}
export function RouterSheet({
children,
trigger,
title,
description,
onOpenChange,
}: RouterSheetProps) {
const [open, setOpen] = React.useState(false)
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
onOpenChange?.(newOpen)
}
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className="mt-4">{children}</div>
</SheetContent>
</Sheet>
)
}
// src/components/ui/router-dialog.tsx
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
interface RouterDialogProps {
children: React.ReactNode
trigger: React.ReactNode
title: string
description?: string
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function RouterDialog({
children,
trigger,
title,
description,
open: controlledOpen,
onOpenChange,
}: RouterDialogProps) {
const [internalOpen, setInternalOpen] = React.useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = onOpenChange ?? setInternalOpen
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="mt-4">{children}</div>
</DialogContent>
</Dialog>
)
}
// src/components/navigation/main-nav.tsx
import { Link, useMatchRoute } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu'
interface NavItem {
to: string
label: string
exact?: boolean
}
interface MainNavProps {
items: NavItem[]
className?: string
}
export function MainNav({ items, className }: MainNavProps) {
const matchRoute = useMatchRoute()
return (
<NavigationMenu className={className}>
<NavigationMenuList>
{items.map((item) => {
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact })
return (
<NavigationMenuItem key={item.to}>
<Link
to={item.to}
className={cn(
navigationMenuTriggerStyle(),
isActive && 'bg-accent text-accent-foreground font-medium',
)}
>
{item.label}
</Link>
</NavigationMenuItem>
)
})}
</NavigationMenuList>
</NavigationMenu>
)
}
// src/components/ui/router-button.tsx
import { createLink } from '@tanstack/react-router'
import { Button, type ButtonProps } from '@/components/ui/button'
import { forwardRef } from 'react'
// Create a router-compatible Button
export const RouterButton = createLink(
forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <Button ref={ref} {...props} />
}),
)
// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { MainNav } from '@/components/navigation/main-nav'
import { RouterButton } from '@/components/ui/router-button'
import { RouterSheet } from '@/components/ui/router-sheet'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/posts/')({
component: PostsPage,
})
const navItems = [
{ to: '/', label: 'Home' },
{ to: '/posts', label: 'Posts', exact: true },
{ to: '/about', label: 'About' },
]
function PostsPage() {
return (
<div className="container mx-auto p-4">
{/* Navigation with active states */}
<MainNav items={navItems} className="mb-8" />
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Posts</h1>
{/* Router-compatible button */}
<RouterButton to="/posts/new" variant="default">
Create Post
</RouterButton>
</div>
{/* Sheet with proper animations */}
<RouterSheet
trigger={<Button variant="outline">Open Menu</Button>}
title="Navigation Menu"
description="Navigate through your posts"
>
<div className="space-y-4">
<p>This sheet animates correctly with TanStack Router!</p>
<RouterButton to="/posts/new" variant="default" className="w-full">
Create New Post
</RouterButton>
</div>
</RouterSheet>
</div>
)
}
Problem: Sheet, Dialog, or other animated components don't animate properly.
Solutions:
Ensure proper portal setup:
// Add to your index.html or root component
<div id="portal-root"></div>
Check CSS imports order:
/* Make sure this comes before your custom styles */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
Use controlled components for complex animations:
const [open, setOpen] = useState(false)
// Controlled instead of uncontrolled
<Sheet open={open} onOpenChange={setOpen}>
Problem: TypeScript errors when using Shadcn/ui components with TanStack Router.
Solution: Use createLink for proper typing:
import { createLink } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
// This provides full type safety
export const RouterButton = createLink(Button)
Problem: Shadcn/ui styles conflict with router or custom styles.
Solutions:
Use CSS layers:
@layer base, components, utilities;
@layer base {
/* Shadcn/ui base styles */
}
@layer components {
/* Your component styles */
}
Increase specificity for router-specific styles:
<Button className="router-active:bg-primary router-active:text-primary-foreground">
Active Button
</Button>
Problem: Dark mode doesn't work properly with route changes.
Solution: Set up theme provider correctly:
// src/components/theme-provider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light' | 'system'
interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
const ThemeProviderContext = createContext<{
theme: Theme
setTheme: (theme: Theme) => void
}>({
theme: 'system',
setTheme: () => null,
})
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider')
return context
}
Before deploying your Shadcn/ui + TanStack Router app: