shadcn/ui テーマカスタマイズ完全ガイド - CSS変数からダークモードまで


shadcn/uiのテーマシステム

shadcn/uiは、コピー&ペーストで使えるReactコンポーネントライブラリですが、その核心にあるのが柔軟なテーマシステムです。CSS変数ベースのデザイントークンにより、簡単にブランドカラーに合わせたカスタマイズが可能です。

テーマシステムの特徴

  • CSS変数ベース: HSL色空間を使用した柔軟なカラーシステム
  • ダークモード対応: ライト/ダークモードの切り替えが簡単
  • アクセシビリティ: WCAG準拠のコントラスト比
  • Tailwind統合: Tailwindのユーティリティクラスと完全統合
  • 型安全: TypeScriptで型付けされたテーマ設定

基本セットアップ

インストール

# Next.jsプロジェクトを作成
npx create-next-app@latest my-app --typescript --tailwind --app

cd my-app

# shadcn/ui初期化
npx shadcn@latest init

# 対話形式で設定
# - Style: Default または New York
# - Base color: Slate, Gray, Zinc, etc.
# - CSS variables: Yes

グローバルCSS設定

/* app/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* ライトモードのカラー */
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;

    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;

    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;

    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;

    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;

    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;

    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;

    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;

    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;

    --radius: 0.5rem;
  }

  .dark {
    /* ダークモードのカラー */
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;

    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;

    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;

    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;

    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;

    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;

    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;

    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;

    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

カスタムテーマの作成

ブランドカラーの適用

例えば、紫色ベースのブランドカラーを適用してみましょう。

/* app/globals.css */

@layer base {
  :root {
    /* 紫ベースのカラーパレット */
    --background: 0 0% 100%;
    --foreground: 270 10% 10%;

    --primary: 262 83% 58%; /* 紫 */
    --primary-foreground: 0 0% 100%;

    --secondary: 270 50% 95%;
    --secondary-foreground: 270 10% 20%;

    --muted: 270 30% 96%;
    --muted-foreground: 270 10% 45%;

    --accent: 280 85% 65%; /* ピンクアクセント */
    --accent-foreground: 0 0% 100%;

    --destructive: 0 84% 60%;
    --destructive-foreground: 0 0% 100%;

    --border: 270 20% 90%;
    --input: 270 20% 90%;
    --ring: 262 83% 58%;

    --radius: 0.75rem; /* より丸みを持たせる */
  }

  .dark {
    --background: 270 20% 8%;
    --foreground: 270 10% 95%;

    --primary: 262 83% 65%;
    --primary-foreground: 270 10% 10%;

    --secondary: 270 20% 15%;
    --secondary-foreground: 270 10% 95%;

    --muted: 270 20% 15%;
    --muted-foreground: 270 10% 70%;

    --accent: 280 85% 70%;
    --accent-foreground: 270 10% 10%;

    --destructive: 0 62% 50%;
    --destructive-foreground: 0 0% 100%;

    --border: 270 20% 20%;
    --input: 270 20% 20%;
    --ring: 262 83% 65%;
  }
}

テーマジェネレーターの活用

shadcn/uiのテーマジェネレーターを使うと、視覚的にテーマを作成できます。

// lib/theme-generator.ts

import { hslToHex, hexToHsl } from '@/lib/color-utils'

export function generateTheme(primaryColor: string) {
  const hsl = hexToHsl(primaryColor)

  return {
    light: {
      primary: `${hsl.h} ${hsl.s}% ${hsl.l}%`,
      primaryForeground: '0 0% 100%',
      secondary: `${hsl.h} ${hsl.s * 0.3}% 96%`,
      accent: `${(hsl.h + 20) % 360} ${hsl.s}% ${Math.min(hsl.l + 10, 95)}%`,
    },
    dark: {
      primary: `${hsl.h} ${hsl.s}% ${Math.min(hsl.l + 10, 80)}%`,
      primaryForeground: `${hsl.h} ${hsl.s * 0.2}% 10%`,
      secondary: `${hsl.h} ${hsl.s * 0.3}% 15%`,
      accent: `${(hsl.h + 20) % 360} ${hsl.s}% ${Math.min(hsl.l + 15, 85)}%`,
    },
  }
}

ダークモード実装

next-themesセットアップ

npm install next-themes
// components/theme-provider.tsx

'use client'

import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// app/layout.tsx

import { ThemeProvider } from '@/components/theme-provider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

テーマ切り替えボタン

// components/theme-toggle.tsx

'use client'

import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'

import { Button } from '@/components/ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'

export function ThemeToggle() {
  const { setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">テーマ切り替え</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme('light')}>
          ライト
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>
          ダーク
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>
          システム
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

複数テーマのサポート

複数のカラーテーマを切り替えられるようにします。

/* app/globals.css */

/* デフォルトテーマ */
:root {
  /* ... */
}

/* 紫テーマ */
[data-theme="purple"] {
  --primary: 262 83% 58%;
  --accent: 280 85% 65%;
  /* ... */
}

/* 緑テーマ */
[data-theme="green"] {
  --primary: 142 76% 36%;
  --accent: 142 76% 50%;
  /* ... */
}

/* 青テーマ */
[data-theme="blue"] {
  --primary: 221 83% 53%;
  --accent: 199 89% 48%;
  /* ... */
}

/* オレンジテーマ */
[data-theme="orange"] {
  --primary: 24 100% 50%;
  --accent: 38 100% 50%;
  /* ... */
}
// components/theme-customizer.tsx

'use client'

import { Button } from '@/components/ui/button'

const themes = [
  { name: 'Default', value: 'default' },
  { name: 'Purple', value: 'purple' },
  { name: 'Green', value: 'green' },
  { name: 'Blue', value: 'blue' },
  { name: 'Orange', value: 'orange' },
]

export function ThemeCustomizer() {
  const [currentTheme, setCurrentTheme] = React.useState('default')

  const handleThemeChange = (theme: string) => {
    setCurrentTheme(theme)
    if (theme === 'default') {
      document.documentElement.removeAttribute('data-theme')
    } else {
      document.documentElement.setAttribute('data-theme', theme)
    }
  }

  return (
    <div className="flex gap-2">
      {themes.map((theme) => (
        <Button
          key={theme.value}
          variant={currentTheme === theme.value ? 'default' : 'outline'}
          onClick={() => handleThemeChange(theme.value)}
        >
          {theme.name}
        </Button>
      ))}
    </div>
  )
}

カスタムコンポーネントのスタイリング

グラデーションボタン

// components/ui/gradient-button.tsx

import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const gradientButtonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-gradient-to-r from-primary to-accent text-primary-foreground hover:opacity-90',
        purple: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90',
        blue: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:opacity-90',
        sunset: 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:opacity-90',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

export interface GradientButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof gradientButtonVariants> {
  asChild?: boolean
}

const GradientButton = React.forwardRef<HTMLButtonElement, GradientButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp
        className={cn(gradientButtonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
GradientButton.displayName = 'GradientButton'

export { GradientButton, gradientButtonVariants }

グラスモーフィズムカード

// components/ui/glass-card.tsx

import * as React from 'react'
import { cn } from '@/lib/utils'

export interface GlassCardProps extends React.HTMLAttributes<HTMLDivElement> {}

const GlassCard = React.forwardRef<HTMLDivElement, GlassCardProps>(
  ({ className, ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn(
          'rounded-xl border border-white/20 bg-white/10 backdrop-blur-lg dark:border-white/10 dark:bg-black/20',
          'shadow-xl shadow-black/5 dark:shadow-white/5',
          className
        )}
        {...props}
      />
    )
  }
)
GlassCard.displayName = 'GlassCard'

export { GlassCard }

アニメーション強化

カスタムアニメーション追加

// tailwind.config.js

module.exports = {
  theme: {
    extend: {
      animation: {
        'fade-in': 'fadeIn 0.5s ease-in-out',
        'slide-up': 'slideUp 0.5s ease-out',
        'slide-down': 'slideDown 0.5s ease-out',
        'scale-in': 'scaleIn 0.3s ease-out',
        'bounce-slow': 'bounce 3s infinite',
        'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
        slideUp: {
          '0%': { transform: 'translateY(20px)', opacity: '0' },
          '100%': { transform: 'translateY(0)', opacity: '1' },
        },
        slideDown: {
          '0%': { transform: 'translateY(-20px)', opacity: '0' },
          '100%': { transform: 'translateY(0)', opacity: '1' },
        },
        scaleIn: {
          '0%': { transform: 'scale(0.9)', opacity: '0' },
          '100%': { transform: 'scale(1)', opacity: '1' },
        },
      },
    },
  },
}

アニメーション付きコンポーネント

// components/animated-card.tsx

import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'

interface AnimatedCardProps {
  children: React.ReactNode
  className?: string
  delay?: number
}

export function AnimatedCard({ children, className, delay = 0 }: AnimatedCardProps) {
  return (
    <Card
      className={cn('animate-slide-up', className)}
      style={{ animationDelay: `${delay}ms` }}
    >
      {children}
    </Card>
  )
}

レスポンシブデザイン

ブレークポイントのカスタマイズ

// tailwind.config.js

module.exports = {
  theme: {
    screens: {
      'xs': '475px',
      'sm': '640px',
      'md': '768px',
      'lg': '1024px',
      'xl': '1280px',
      '2xl': '1536px',
      '3xl': '1920px',
    },
  },
}

レスポンシブコンポーネント

// components/responsive-grid.tsx

export function ResponsiveGrid({ children }: { children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
      {children}
    </div>
  )
}

フォントのカスタマイズ

// app/layout.tsx

import { Inter, Noto_Sans_JP } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const notoSansJP = Noto_Sans_JP({ subsets: ['latin'], variable: '--font-noto-sans-jp' })

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}
// tailwind.config.js

module.exports = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'var(--font-noto-sans-jp)', 'sans-serif'],
      },
    },
  },
}

まとめ

shadcn/uiのテーマシステムは、CSS変数ベースの柔軟な設計により、簡単にカスタマイズできます。以下のポイントを押さえましょう:

  • CSS変数: HSL色空間で直感的なカラーカスタマイズ
  • ダークモード: next-themesで簡単実装
  • 複数テーマ: data属性で切り替え可能
  • アニメーション: Tailwindのカスタムアニメーションで強化
  • レスポンシブ: モバイルファーストのデザイン

これらの技術を組み合わせることで、ブランドに合った美しいUIを短時間で構築できます。ぜひshadcn/uiのテーマカスタマイズを極めてください!