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>
)
}
2단계: 라우터 호환 버튼 링크 만들기
// 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 또는 다른 애니메이션 컴포넌트가 올바르게 애니메이션되지 않습니다.
해결 방법:
-
포털이 올바르게 설정되었는지 확인합니다.
// Add to your index.html or root component
<div id="portal-root"></div> -
CSS 가져오기 순서를 확인합니다.
/* Make sure this comes before your custom styles */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities'; -
복잡한 애니메이션에는 제어 컴포넌트를 사용합니다.
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 스타일이 라우터 또는 사용자 지정 스타일과 충돌합니다.
해결 방법:
-
CSS 레이어를 사용합니다.
@layer base, components, utilities;
@layer base {
/* Shadcn/ui base styles */
}
@layer components {
/* Your component styles */
} -
라우터별 스타일의 특이성을 높입니다.
<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가 성능 문제를 일으키지 않습니다.
- 느린 기기에서도 애니메이션 성능이 허용 가능한 수준입니다.
관련 리소스
- Shadcn/ui TanStack Router 설치 - 공식 통합 가이드
- TanStack Router createLink API - 컴포넌트 통합을 위한 API 문서
- Shadcn/ui 컴포넌트 - 전체 컴포넌트 문서