shadcn/ui'de koyu tema, sayfanın kök öğesine (html) dark sınıfı eklenerek açılır. globals.css içindeki .dark bloğu tüm renk değişkenlerinin koyu değerlerini tanımlar; sınıf eklendiği anda bileşenler bu değerleri kullanmaya başlar. Geriye kalan iş, bu sınıfı kullanıcının seçimine ve sistem tercihine göre ekleyip kaldıran küçük bir sağlayıcı ile bir tema düğmesi yazmaktır. Next.js için belgeler next-themes paketini önerir, Vite için sağlayıcıyı kendiniz yazarsınız.

Bu yazıda önce mekanizmayı, sonra Next.js, Vite, Astro ve Remix sayfalarındaki kurulumları sırayla anlatıyor ve farklarını bir tabloda topluyoruz. Renk değişkenlerinin kendisi shadcn tema ve CSS değişkenleri yazısının konusu. Framework kullanmadan, yalnızca CSS ile prefers-color-scheme üzerinden karanlık mod yapmak isterseniz CSS dark mode rehberine bakabilirsiniz.

Mekanizma: .dark sınıfı ve özel varyant

Mekanizma: .dark sınıfı ve özel varyant

Manuel kurulum sayfasındaki globals.css dosyası iki parçayla koyu temayı hazırlar. İlki Tailwind'e dark: varyantının ne zaman geçerli olacağını söyleyen satırdır: .dark sınıfına sahip bir öğenin içindeki her şey. İkincisi, aynı değişken adlarının koyu değerlerini taşıyan .dark bloğudur:

css
@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
}

Bu yapı sayesinde bileşenlerin çoğu koyu tema için ayrıca sınıf taşımaz; bg-background her iki temada da doğru rengi verir. Bazı bileşenler ince ayar için dark: sınıflarını yine kullanır; örneğin button.tsx içindeki outline varyantında dark:bg-input/30 vardır. İki durumda da tetikleyici aynıdır: kök öğedeki dark sınıfı. Aşağıdaki tüm kurulumlar bu sınıfı yönetmenin farklı yollarıdır.

Next.js: next-themes ile kurulum

Next.js sayfası dört adımdan oluşur. İlk adım paketi kurmaktır (pnpm, yarn veya bun kullanıyorsanız kendi ekleme komutunuzu kullanın):

bash
npm install next-themes

İkinci adımda components/theme-provider.tsx dosyası oluşturulur. Bu dosya next-themes'in sağlayıcısını sarar ve en üstteki "use client" yönergesiyle istemci bileşeni olarak işaretlenir; App Router'da düzen dosyası sunucu bileşeni olduğu için bu ayrım gereklidir. Kavramın kendisi için Next.js App Router yazısına bakabilirsiniz.

typescript
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Üçüncü adımda sağlayıcı kök düzene eklenir ve html etiketine suppressHydrationWarning verilir. app/layout.tsx dosyasının belgedeki hali:

typescript
import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}

Buradaki dört prop belgenin önerdiği ayarlardır. attribute="class" temanın html öğesine sınıf olarak yazılmasını sağlar; shadcn/ui'nin .dark seçicisiyle eşleşmesi için gereken budur. defaultTheme="system" ve enableSystem, kullanıcı bir seçim yapmadıysa işletim sisteminin tercihini izler. disableTransitionOnChange tema değişirken CSS geçişlerinin devreye girmesini engeller. suppressHydrationWarning ise sunucuda üretilen html ile istemcide sınıfı eklenmiş html arasındaki farkın uyarı olarak raporlanmasını bastırır. Örnekteki RootLayoutProps türü belgede tanımlanmaz; kendi projenizde Next.js'in oluşturduğu düzen türünü kullanın.

Depodaki monorepo şablonu aynı sağlayıcıyı components/providers.tsx içinde kullanır ve bu dört propa ek olarak enableColorScheme verir. Bu, şablonun tercihi olarak görülebilir; belgedeki dört prop temel kurulum için yeterlidir.

Dördüncü adım tema düğmesidir. Belgedeki mode-toggle örneği DropdownMenu içinde üç seçenek sunar ve next-themes paketinin useTheme kancasından gelen setTheme fonksiyonunu çağırır. Etiketleri Türkçeleştirilmiş hali:

typescript
"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 ModeToggle() {
  const { setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
          <span className="sr-only">Temayı değiştir</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>Açık</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>Koyu</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>Sistem</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Düğmenin içinde iki ikon üst üste durur. Güneş ikonu normalde görünür ve dark:scale-0 ile koyu temada küçülerek kaybolur; ay ikonu absolute konumla aynı yerde durur, normalde scale-0 ile gizlidir ve koyu temada dark:scale-100 ile görünür. Görsel olarak yalnızca ikon kaldığı için sr-only sınıflı metin ekran okuyucular için düğmenin amacını söyler; bu konuda ARIA ve erişilebilir adlar yazısına bakabilirsiniz. Bileşen Button ve DropdownMenu kullandığı için önce bunları eklemeniz gerekir:

bash
npx shadcn@latest add button dropdown-menu

Vite: kendi ThemeProvider bileşeniniz

Vite sayfası harici bir paket kullanmaz; components/theme-provider.tsx dosyasında küçük bir React bağlamı (context) yazmanızı ister. Sağlayıcı üç tema değerini bilir: "dark", "light" ve "system". Kısaltılmış haliyle çekirdek mantık şudur:

typescript
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])

İlk değer localStorage içinden okunur; anahtarın varsayılan adı vite-ui-theme, varsayılan tema systemdir. Tema her değiştiğinde efekt önce html öğesinden light ve dark sınıflarını kaldırır, sonra doğru olanı ekler. system seçiliyse matchMedia ile işletim sisteminin tercihi okunur. Bağlama verilen setTheme ise seçimi önce localStorage içine yazar, sonra durumu günceller; böylece sayfa yenilendiğinde seçim korunur. Dosya ayrıca bileşenlerin temaya erişmesi için bir useTheme kancası dışa aktarır.

Sağlayıcı uygulamanın köküne eklenir. Belgedeki App.tsx örneği varsayılan temayı koyu yapar:

typescript
import { ThemeProvider } from "@/components/theme-provider"

function App() {
  return (
    <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
      {children}
    </ThemeProvider>
  )
}

export default App

Bu örnekteki {children} bir yer tutucudur; App fonksiyonu children almadığı için gerçek projede buraya kendi bileşenlerinizi yazarsınız. Vite'in tema düğmesi Next.js örneğiyle neredeyse aynıdır; tek fark useTheme kancasının next-themes yerine @/components/theme-provider yolundan içe aktarılmasıdır. Projenin kurulumu için shadcn Vite kurulumu yazısına bakın.

Vite sağlayıcısındaki küçük bir ayrıntıya dikkat edin: useTheme, bağlam değeri undefined ise "useTheme must be used within a ThemeProvider" hatasını fırlatacak biçimde yazılmıştır. Ancak bağlam createContext(initialState) ile varsayılan bir değerle oluşturulduğu için sağlayıcı dışında çağrıldığında bu varsayılan değer döner ve hata görünmeyebilir; bu durumda setTheme hiçbir şey yapmaz. Düğmeniz tepki vermiyorsa ilk bakacağınız yer, bileşenin gerçekten ThemeProvider içinde olup olmadığıdır.

Astro: satır içi betik ve istemci bileşeni

Astro: satır içi betik ve istemci bileşeni

Astro sayfası iki parça kullanır. İlki src/pages/index.astro içine konan is:inline işaretli bir betiktir: localStorage içinde theme anahtarı varsa onu, yoksa sistem tercihini okuyup html öğesine dark sınıfını ekler ya da kaldırır. Betik ayrıca bir MutationObserver ile html öğesinin sınıf listesini izler ve dark sınıfı her değiştiğinde seçimi localStorage içine yazar.

İkinci parça src/components/ModeToggle.tsx dosyasındaki React bileşenidir. Bu bileşen tema durumunu kendi içinde tutar (değerler "theme-light", "dark" ve "system"), ilk yüklemede html üzerindeki sınıfa bakarak başlangıç değerini belirler ve tema değiştiğinde sınıfı günceller. Kalıcılığı bileşen değil, satır içi betikteki gözlemci sağlar. Bileşen sayfaya client:load yönergesiyle eklenir:

typescript
<ModeToggle client:load />

Remix: oturum çereziyle tema

Remix sayfası temayı sunucu tarafında, bir oturum çerezinde saklar. Önce app/tailwind.css dosyasında .dark seçicisinin yanına :root[class~="dark"] eklenir; ardından remix-themes paketi kurulur. app/sessions.server.tsx dosyası theme adlı, httpOnly ve sameSite: "lax" ayarlı bir çerez oturumu ve bir tema çözümleyici oluşturur; üretim ortamında alan adı ve secure ayarı eklenir. Belgedeki secrets değeri bir örnektir; gerçek projede kendi gizli değerinizi kullanmalısınız.

app/root.tsx içinde yükleyici (loader) çerezden temayı okur, ThemeProvider bu değeri specifiedTheme olarak alır ve themeAction="/action/set-theme" ile temanın hangi eylem rotasına yazılacağını bilir. html öğesinin sınıfı clsx(theme) ile verilir ve PreventFlashOnWrongTheme bileşeni ilk yüklemede yanlış temanın görünmesini engeller. Son olarak app/routes/action.set-theme.ts dosyası createThemeAction ile bu eylemi tanımlar. Remix belgesindeki tema düğmesinde yalnızca açık ve koyu seçenekleri vardır, sistem seçeneği yoktur.

İlk yüklemede yanlış tema görünmesi

Koyu tema kurulumlarında en çok karşılaşılan sorun, sayfa ilk açıldığında kısa bir süre açık temanın görünüp sonra koyuya geçmesidir. Bunun nedeni, dark sınıfının tarayıcı sayfayı çizdikten sonra eklenmesidir. Belgelerdeki dört kurulum bu konuya farklı yaklaşır ve farkı bilmek hangi framework'te neye dikkat edeceğinizi gösterir.

Remix kurulumu sorunu açıkça hedefler: tema sunucuda çerezden okunur, html öğesinin sınıfı sunucuda üretilen HTML'e yazılır ve ayrıca PreventFlashOnWrongTheme bileşeni kullanılır. Astro kurulumunda tema kararı, sayfanın en başında çalışan is:inline betikte verilir; betik paketlenmeden doğrudan sayfaya yazıldığı için React bileşeni yüklenmeyi beklemez. Next.js kurulumunda bu işi next-themes üstlenir ve suppressHydrationWarning ihtiyacı da tam bu nedenle doğar: sınıf sunucunun ürettiği HTML'de yokken istemcide eklenir.

Vite belgesindeki sağlayıcı ise sınıfı bir useEffect içinde ekler. React efektleri bileşen ilk kez ekrana çizildikten sonra çalıştığı için bu kodda, özellikle kayıtlı tema koyuyken, ilk karede açık temanın görünmesi mümkündür. Belge bu konuda ek bir adım önermez. Sorun sizin için önemliyse çözüm, Astro örneğindeki gibi temayı uygulama yüklenmeden önce çalışan küçük bir betikle html öğesine yazmaktır; bunu yaparken Vite sağlayıcısıyla aynı localStorage anahtarını (vite-ui-theme) okumayı unutmayın.

Monorepo ve paylaşılan bileşenlerde tema

Bileşenleriniz ayrı bir pakette duruyorsa tema sağlayıcısı uygulama tarafında kalır. Depodaki Next.js monorepo şablonunda sağlayıcı apps/web/components/providers.tsx dosyasındadır ve app/layout.tsx içinde html etiketine yine suppressHydrationWarning verilir. Renk değişkenlerini içeren globals.css ise paylaşılan ui paketindedir ve aynı @custom-variant dark satırını taşır. Monorepo belgesi, kancaların da paylaşılan paketten içe aktarılabileceğini @workspace/ui/hooks/use-theme örneğiyle gösterir. Kurulumun ayrıntıları shadcn monorepo yazısında.

Framework karşılaştırması

Framework karşılaştırması: Next.js, Vite, Astro, Remix
FrameworkPaketSeçimin saklandığı yerSistem seçeneği (belgedeki örnekte)
Next.jsnext-themesnext-themes yönetirVar (defaultTheme="system", enableSystem)
ViteYok, kendi sağlayıcınızlocalStorage, anahtar vite-ui-themeVar
AstroYok, satır içi betiklocalStorage, anahtar themeVar
Remixremix-themesOturum çerezi (theme)Düğmede yok, yalnızca Light ve Dark

Sık yapılan hatalar

  • attribute="class" vermemek: next-themes temayı sınıf olarak yazmazsa .dark seçicisi hiç eşleşmez.
  • suppressHydrationWarning'i unutmak: Next.js'te html öğesindeki sınıf farkı geliştirme sırasında uyarı olarak görünür.
  • ThemeProvider dosyasında "use client" olmaması: next-themes istemci tarafında çalışır; sağlayıcıyı sunucu bileşeni olarak bırakmayın.
  • Tema düğmesini sağlayıcının dışına koymak: Vite sağlayıcısında bu durum sessizce çalışmayan bir düğmeye yol açabilir.
  • .dark bloğunu eksik bırakmak: Yeni eklediğiniz bir renk değişkeninin koyu değerini yazmazsanız koyu temada açık renk görünür.
  • Gerekli bileşenleri eklememek: Belgedeki tema düğmeleri button ve dropdown-menu bileşenlerini kullanır.

Sık Sorulan Sorular

Koyu tema için bileşenlere ayrı sınıflar yazmam gerekir mi?

CSS değişkenleri yaklaşımında genellikle hayır. Bileşenler anlamsal renk sınıfları kullanır; .dark bloğundaki değerler devreye girdiğinde renkler kendiliğinden değişir.

Varsayılan temayı koyu yapabilir miyim?

Evet. Next.js'te defaultTheme propunu, Vite'de sağlayıcının defaultTheme propunu "dark" yaparsınız; Vite belgesindeki örnek zaten böyledir.

Sistem tercihi değişince tema da değişir mi?

Next.js kurulumunda enableSystem ve defaultTheme="system" ile sistem tercihi izlenir. Vite sağlayıcısı ise tercihi tema değiştiğinde matchMedia ile okur; belgedeki kodda sistem tercihindeki sonraki değişiklikleri dinleyen bir kod bulunmaz.

Tema seçimini sunucu tarafında bilmem gerekirse ne yapmalıyım?

Vite ve Astro örneklerinde seçim tarayıcının localStorage alanında durduğu için sunucu bunu göremez. Belgelerdeki örnekler arasında seçimi sunucuya taşıyan tek yaklaşım Remix kurulumudur: tema bir oturum çerezine yazılır ve her istekte yükleyici tarafından okunur. Sunucuda üretilen HTML'in baştan doğru temayla gelmesini istiyorsanız bu çerez tabanlı yaklaşımı örnek alabilirsiniz.

Next.js 15 ve React 19 ile bu kurulum çalışır mı?

Belgedeki adımlar framework sürümünden bağımsız yazılmıştır. npm ile kurulumda peer dependency uyarısı görürseniz React 19 peer dependency yazısına bakın.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: shadcn/ui öğrenme rehberi · tema ve CSS değişkenleri · Next.js kurulumu · Vite kurulumu · Dropdown Menu · CSS dark mode