본문으로 건너뛰기

TanStack Router와 Shadcn/ui 통합 방법

이 가이드에서는 일반적인 애니메이션 및 호환성 문제의 해결 방법을 포함해 TanStack Router에서 Shadcn/ui를 설정하는 방법을 설명합니다.

빠른 시작

예상 소요 시간: 30~45분
난이도: 중급
사전 요구 사항: 기존 TanStack Router 프로젝트

수행할 작업

  • TanStack Router에서 Shadcn/ui를 설치하고 구성합니다.
  • 모달, 시트, 다이얼로그의 애니메이션 문제를 해결합니다.
  • 타입 안전한 내비게이션 컴포넌트를 만듭니다.
  • 올바른 스타일 통합을 설정합니다.
  • 일반적인 호환성 문제를 해결합니다.

설치 및 설정

1단계: Shadcn/ui 설치

옵션 1: TanStack Router 템플릿으로 새 프로젝트 만들기

npx create-tsrouter-app@latest my-app --template file-router --tailwind --add-ons shadcn

옵션 2: 기존 TanStack Router 프로젝트에 추가하기

npx shadcn@latest init

2단계: components.json 구성

TanStack Router와 호환되도록 components.json을 만들거나 업데이트합니다.

{
"$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"
}
}

3단계: 필수 컴포넌트 추가

가장 일반적으로 사용하는 컴포넌트를 설치합니다.

npx shadcn@latest add button
npx shadcn@latest add navigation-menu
npx shadcn@latest add sheet
npx shadcn@latest add dialog

애니메이션 문제 해결

1단계: 올바른 DOM 구조 설정

포털과 애니메이션을 지원하도록 루트 라우트를 업데이트합니다.

// 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 />
</>
),
})

2단계: 라우터 호환 Sheet 컴포넌트 만들기

Shadcn/ui Sheet 컴포넌트에서 애니메이션 문제가 발생할 수 있습니다. 래퍼를 만듭니다.

// 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>
)
}

3단계: 라우터 호환 Dialog 컴포넌트 만들기

// 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>
)
}

내비게이션 컴포넌트 만들기

1단계: 라우터 호환 내비게이션 메뉴

// 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} />
}),
)

3단계: 사용 예시

// 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>
)
}

일반적인 문제

애니메이션 컴포넌트가 작동하지 않음

문제: Sheet, Dialog 또는 다른 애니메이션 컴포넌트가 올바르게 애니메이션되지 않습니다.

해결 방법:

  1. 포털이 올바르게 설정되었는지 확인합니다.

    // Add to your index.html or root component
    <div id="portal-root"></div>
  2. CSS 가져오기 순서를 확인합니다.

    /* Make sure this comes before your custom styles */
    @import 'tailwindcss/base';
    @import 'tailwindcss/components';
    @import 'tailwindcss/utilities';
  3. 복잡한 애니메이션에는 제어 컴포넌트를 사용합니다.

    const [open, setOpen] = useState(false)

    // Controlled instead of uncontrolled
    <Sheet open={open} onOpenChange={setOpen}>

라우터 통합 시 TypeScript 오류

문제: TanStack Router에서 Shadcn/ui 컴포넌트를 사용할 때 TypeScript 오류가 발생합니다.

해결 방법: 올바른 타입 지정을 위해 createLink를 사용합니다.

import { createLink } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'

// This provides full type safety
export const RouterButton = createLink(Button)

스타일 충돌

문제: Shadcn/ui 스타일이 라우터 또는 사용자 지정 스타일과 충돌합니다.

해결 방법:

  1. CSS 레이어를 사용합니다.

    @layer base, components, utilities;

    @layer base {
    /* Shadcn/ui base styles */
    }

    @layer components {
    /* Your component styles */
    }
  2. 라우터별 스타일의 특이성을 높입니다.

    <Button className="router-active:bg-primary router-active:text-primary-foreground">
    Active Button
    </Button>

다크 모드 통합

문제: 라우트가 변경될 때 다크 모드가 올바르게 작동하지 않습니다.

해결 방법: 테마 provider를 올바르게 설정합니다.

// 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
}

프로덕션 체크리스트

Shadcn/ui + TanStack Router 앱을 배포하기 전에 다음을 확인합니다.

스타일

  • 모든 Shadcn/ui 컴포넌트가 올바르게 렌더링됩니다.
  • 라우트 변경 시 애니메이션이 올바르게 작동합니다.
  • 다크 모드 통합이 작동합니다(해당하는 경우).
  • CSS 충돌이 해결되었습니다.
  • 반응형 디자인을 테스트했습니다.

기능

  • 내비게이션 컴포넌트가 라우터 상태와 함께 작동합니다.
  • 활성 상태가 올바르게 반영됩니다.
  • TypeScript 컴파일이 성공합니다.
  • 모든 시트, 다이얼로그, 모달이 올바르게 애니메이션됩니다.

성능

  • 번들 크기가 최적화되었습니다(tree shaking이 작동합니다).
  • CSS-in-JS가 성능 문제를 일으키지 않습니다.
  • 느린 기기에서도 애니메이션 성능이 허용 가능한 수준입니다.