Öğreticiler

React'ta Dinamik Renk Temalama: CSS Değişkenlerinin Ötesinde

5 dk okuma

Çoğu temalama öğreticisi karanlık mod geçişinde durur. <html> üzerinde bir sınıf değiştirir, bir avuç CSS değişkenini döndürürsünüz, bitti. Bu yaygın bir durumu kapsar ama daha ilginç sorunu kaçırır: peki kullanıcılar kendi marka renklerini seçebiliyorsa? SaaS ürününüz her biri kendi kimliğine sahip birden fazla istemciye hizmet ediyorsa? Tema, çalışma zamanında tek bir giriş hex değerinden eksiksiz harmonik bir palet oluşturması gerekiyorsa?

Dinamik renk temalama tam burada başlar — ve yalnızca CSS değişkenleri yeterli değildir. Çalışma zamanı renk algoritmalarına, navigasyonda hayatta kalan React durumuna, kalıcılık stratejilerine ve bir kaydırıcı her hareket ettiğinde tüm bileşen ağacınızı yeniden oluşturmayan bir mimariye ihtiyacınız vardır.

Bu rehber bu katmanların her birini derinlemesine ele alır.


React Temaları için CSS Değişken Yaklaşımı

Temel: Semantik Token Mimarisi

Her dinamik tema sistemi aynı temelle başlar: renk değeri yerine semantik rollerine göre adlandırılmış CSS özel özellikleri. Bir değişkeni --blue-500 olarak adlandırmayın. --brand-primary olarak adlandırın. Değer değişebilir; rol değişemez:

/* globals.css */
:root {
  /* Semantik tokenlar — bileşenlerin kullandığı bunlardır */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

Bileşenler, ham renkler değil tokenları referans alır:

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

JavaScript'te --brand-primary'yi güncellediğinizde, onu referans alan her bileşen anında güncellenir — React yeniden oluşturma olmadan, prop drilling olmadan, bağlam abonelikleri olmadan. CSS kaskadı bunu yönetir.

JavaScript'ten Token Yazma

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

Renk Algoritmalarıyla Çalışma Zamanı Tema Üretimi

Tam Token Setini Üretme

Marka hex'inden ihtiyacınız olan tüm tokenları üreten eksiksiz bir fonksiyon:

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // WCAG göreceli parlaklığa göre marka metni siyah mı beyaz mı olacak
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // OKLCH'de algısal olarak düzgün adımlarla açık tondan koyu tona ölçek
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],
    '--brand-muted': scale[2],
    '--on-brand': onBrand,
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-500': scale[5],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

Erişilebilir Renk Çiftleri

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// Beyaz arka planda marka renkli metin etiketi için
const safeLabel = ensureContrast('#FFD700', '#FFFFFF');

Tema Durum Yönetimi için React Context

ThemeContext Mimarisi

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

Renk Seçici Bileşeni

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Marka Rengi
      </label>
      <input
        id="brand-picker"
        type="color"
        value={localHex}
        onChange={handleChange}
      />
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
      />
    </div>
  );
}

150ms gecikme kritiktir — onsuz, hex girişine yazarken generateThemeTokens her tuş vuruşunda çalışır.


Tailwind CSS Dinamik Renk Sınıfları

CSS Değişken Köprüsü

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

Artık bg-brand, text-brand ve hover:bg-brand-hover, çalışma zamanında --brand-primary'nin tuttuğu değeri yansıtan geçerli Tailwind sınıflarıdır:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Birincil Eylem
</button>

Gerçekten Dinamik Renkler için Satır İçi Stiller

// Gerçekten dinamik renkler için doğru:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
/>

// Bu çalışma zamanında ÇALIŞMAZ (sınıf stil sayfasında yok):
<div className={`bg-[${swatch.hex}]`} /> // Yanlış

Kullanıcı Renk Tercihlerini Kalıcı Hale Getirme

Oturumlar Arasında Kalıcılık için localStorage

const [state, setState] = useState<ThemeState>(() => {
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

Yüklemede Varsayılan Tema Flaşını Önleme

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="tr" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Erişilebilir Karanlık Mod Varyantları Üretme

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000',
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

Temel Çıkarımlar

  • CSS özel özellikleri temelin temelidir — rol bazlı adlandırılmış semantik tokenlar, bileşenleri değiştirmeden tam tema değişikliklerine izin verir.
  • Tek bir marka hex'inden çalışma zamanı token üretimi şunları gerektirir: bir parlaklık/karanlık skalası, marka metni için WCAG kontrast denetimi ve karanlık mod varyantları için ayrı hesaplama.
  • React context tema durumunu yönetir (aktif marka hex, açık/karanlık mod) ve tokenları DOM'a yan etki olarak uygular.
  • Renk seçiciler veya kaydırıcılar gibi sürekli girdilere bağlandığında pahalı renk hesaplamalarını (150ms) geciktirin.
  • Tailwind CSS için: bg-brand yardımcılarının çalışma zamanı token değerini yansıtması amacıyla @theme'de bir CSS değişken köprüsü tanımlayın.
  • Varsayılan tema flaşını <head>'de oluşturmadan önce en kritik tokenları uygulayan senkron bir engelleme komut dosyasıyla önleyin.
  • SSR uygulamaları için kullanıcı başına tema için temayı çerezde saklayın; böylece sunucu doğru başlangıç HTML'sini oluşturabilir.
  • Oluşturulan renk skalalarını doğrulamak için Ton Üreteci'ni ve tam tema paletindeki harmoniyi ve kontrast oranlarını doğrulamak için Palet Oluşturucu'yu kullanın.

İlgili Renkler

İlgili Markalar

İlgili Araçlar