طراحی وب

Dark Mode: اصول طراحی و پیاده‌سازی

طراحی Dark Mode با Contrast، prefers-color-scheme و color-scheme؛ نمونه کامل انتخاب سیستم، روشن و تاریک با ذخیره امن ترجیح کاربر.

1404/11/02 4 دقیقه مطالعه 2,401 بازدید
Dark Mode: اصول طراحی و پیاده‌سازی

Dark Mode را با معکوس کردن رنگ‌های صفحه شروع نکنید. یک فرم ثبت سفارش را در نظر بگیرید: متن، خطای اعتبارسنجی، مرز input، دکمه اصلی و Focus باید در هر دو حالت قابل تشخیص بمانند. اگر فقط پس‌زمینه تیره شود، ممکن است خطای قرمز روی آن خوانا نباشد یا کادر انتخاب از سطح اطراف جدا نشود. مسئله اصلی، حفظ نقش رنگ‌ها و رفتار رابط هنگام تغییر Theme است.

انتخاب حالت تاریک به نیاز کاربر و محصول مربوط است. بعضی افراد حالت روشن را ترجیح می‌دهند و بعضی می‌خواهند صفحه از تنظیم سیستم پیروی کند. سه انتخاب «سیستم»، «روشن» و «تاریک» این تفاوت را واضح بیان می‌کند. لازم نیست با یک دکمه دوحالته کاربر را مجبور کنید ترجیح سیستم را هر بار دستی بازسازی کند. در این راهنما همین قرارداد را با CSS و JavaScript معمولی پیاده می‌کنیم.

رنگ‌ها را بر اساس نقش تعریف کنید

به جای متغیرهایی مانند white و black، نام‌هایی مانند background، surface، text، muted و border انتخاب کنید. یک سطح کارت در حالت روشن ممکن است سفید باشد، اما همان نقش در حالت تاریک به رنگ دیگری نیاز دارد. اگر component مستقیماً از نام رنگ ثابت استفاده کند، تغییر Theme به اصلاح پراکنده فایل‌ها تبدیل می‌شود. نام نقش توضیح می‌دهد رنگ برای چه چیزی انتخاب شده است.

برای دکمه اصلی، رنگ پس‌زمینه و متن روی آن را یک جفت ببینید. انتخاب accent روشن بدون بررسی on-accent ممکن است دکمه‌ای با متن ناخوانا بسازد. همین موضوع درباره برچسب هشدار، پیام موفقیت و لینک وجود دارد. در Design System، هر جفت باید همراه حالت عادی، hover، disabled و Focus تعریف شود. انتقال یک رنگ برند به حالت تاریک به‌تنهایی قرارداد کاملی برای آن نمی‌سازد.

هیچ رنگی مانند #121212 برای همه محصولات بهترین پس‌زمینه نیست. سیاه خالص نیز به خودی خود ممنوع نیست. جنس محتوا، نمایشگر، خوانایی و ترکیب رنگ‌های مجاور در تصمیم دخیل‌اند. چند سطح محدود با تفاوت قابل تشخیص تعریف کنید؛ تغییر سطح نباید فقط به سایه‌ای وابسته باشد که روی زمینه تیره تقریباً دیده نمی‌شود. مرز، فاصله و عنوان هم به جدا کردن گروه‌ها کمک می‌کنند.

Contrast را برای هر جفت واقعی بررسی کنید

در سطح AA، معیار Contrast Minimum در WCAG برای متن معمولی نسبت حداقل 4.5:1 و برای متن بزرگ حداقل 3:1 را تعیین می‌کند. تعریف متن بزرگ هم در همان معیار آمده است؛ صرف نام‌گذاری یک کلاس به large آن را مشمول استثنا نمی‌کند. نسبت باید با رنگ متن و پس‌زمینه واقعی محاسبه شود، از جمله وقتی شفافیت یا لایه دیگری رنگ نهایی را تغییر می‌دهد.

اعداد متن را به همه عناصر تعمیم ندهید. Non-text Contrast درباره اطلاعات بصری لازم برای تشخیص component و حالت آن توضیح می‌دهد. مرز ضروری input، نشانه انتخاب و بعضی آیکون‌ها مسئله جداگانه دارند. همه تزئینات به مرز پررنگ نیاز ندارند، اما کنترل نباید برای کاربر ناپدید شود. این تفکیک مانع می‌شود صفحه با خطوط اضافی شلوغ شود یا عناصر لازم حذف شوند.

رنگ تنها حامل معنا نباشد. کنار خطا توضیح مشخص بگذارید و فیلد مربوط را با متن و ارتباط برنامه‌ای معرفی کنید. لینک داخل متن با نشانه قابل تشخیص از جمله underline دیده شود. نمودار هم علاوه بر رنگ می‌تواند برچسب و شکل متفاوت داشته باشد. عوض شدن Theme نباید این نشانه‌ها را از بین ببرد؛ کاربری که رنگ‌ها را به‌خوبی تفکیک نمی‌کند همچنان باید پیام را بفهمد.

ترجیح سیستم و انتخاب دستی دو ورودی‌اند

prefers-color-scheme ترجیح رنگ اعلام‌شده از طرف سیستم یا مرورگر را نشان می‌دهد. این Media Query تصمیم قطعی کاربر درباره همین سایت را نمی‌داند. وقتی او در سایت حالت روشن را انتخاب می‌کند، انتخاب صریح باید بر ترجیح سیستم مقدم باشد. فقط در حالت «سیستم» است که تغییر تنظیم سیستم باید ظاهر صفحه را تغییر دهد.

CSS بدون JavaScript هم می‌تواند از این ترجیح پیروی کند. متغیرهای حالت روشن را روی root بگذارید و داخل Media Query، در نبود انتخاب صریح، مقادیر تاریک را جایگزین کنید. این fallback باعث می‌شود محتوا با خاموش بودن script قابل استفاده بماند. کنترل انتخاب Theme را می‌توان تا آماده شدن JavaScript مخفی نگه داشت؛ نمایش کنترلی که هیچ کاری نمی‌کند توضیح روشنی به کاربر نمی‌دهد.

ویژگی color-scheme به مرورگر می‌گوید چه طرح رنگی برای اجزای تحت کنترل خودش مناسب است. این ویژگی روی مواردی مثل کنترل‌های فرم اثر دارد، اما تمام رنگ‌های اختصاصی سایت را خودکار طراحی نمی‌کند. همچنان باید متغیرهای خودتان را تعریف کنید. برای انتخاب صریح، مقدار light یا dark و برای پیروی از سیستم، پشتیبانی light dark را اعلام کنید.

نمونه کامل با انتخاب سه‌حالته

کد زیر یک فایل مستقل است. آن را با نام index.html ذخیره کنید و برای استفاده پایدار از Storage، از سرور محلی پروژه باز کنید. برای نمونه اگر Python در سیستم دارید، در پوشه فایل دستور python -m http.server 8000 را اجرا و آدرس http://localhost:8000 را باز کنید. هیچ package، تصویر یا فایل فونت جداگانه لازم ندارد. مثال یک فرم نمایشی است و داده‌ای به سرور نمی‌فرستد.

<!doctype html>
<html lang="fa" dir="rtl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>انتخاب Theme</title>
  <script>
    try {
      const saved = localStorage.getItem('example-theme');
      if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
    } catch {}
  </script>
  <style>
    :root { color-scheme: light dark; --bg:#f4f6f9; --surface:#fff; --text:#17212b; --muted:#465463; --border:#6b7785; --accent:#164ca4; --on-accent:#fff; }
    :root[data-theme="light"] { color-scheme: light; }
    :root[data-theme="dark"] { color-scheme: dark; --bg:#111827; --surface:#1f2937; --text:#f3f4f6; --muted:#d1d5db; --border:#9ca3af; --accent:#93c5fd; --on-accent:#111827; }
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme]) { --bg:#111827; --surface:#1f2937; --text:#f3f4f6; --muted:#d1d5db; --border:#9ca3af; --accent:#93c5fd; --on-accent:#111827; }
    }
    * { box-sizing:border-box; }
    body { margin:0; background:var(--bg); color:var(--text); font:1rem/1.9 system-ui,sans-serif; }
    main { max-inline-size:42rem; margin-inline:auto; padding:2rem 1rem; }
    section { padding:1.5rem; border:1px solid var(--border); border-radius:.7rem; background:var(--surface); }
    label { display:block; margin-block:1rem .25rem; }
    select,input,button { font:inherit; }
    select,input { max-inline-size:100%; padding:.4rem .7rem; border:1px solid var(--border); border-radius:.3rem; background:var(--surface); color:var(--text); }
    button { margin-block-start:1rem; padding:.45rem 1rem; border:0; border-radius:.3rem; background:var(--accent); color:var(--on-accent); }
    a { color:var(--accent); text-decoration:underline; }
    :focus-visible { outline:3px solid var(--accent); outline-offset:4px; }
    .muted { color:var(--muted); }
    [hidden] { display:none; }
  </style>
</head>
<body>
  <main>
    <h1>ظاهر دلخواه شما</h1>
    <div id="theme-control" hidden>
      <label for="theme">طرح رنگی</label>
      <select id="theme"><option value="system">سیستم</option><option value="light">روشن</option><option value="dark">تاریک</option></select>
      <p id="theme-status" class="muted" aria-live="polite"></p>
    </div>
    <section>
      <h2>فرم نمایشی</h2>
      <p>این فرم داده‌ای ذخیره نمی‌کند. <a href="#explanation">توضیح نمونه</a></p>
      <form id="demo-form">
        <label for="name">نام</label><input id="name" name="name" autocomplete="name" required>
        <br><button type="submit">نمایش پیام</button>
        <p id="form-status" role="status"></p>
      </form>
    </section>
    <p id="explanation" class="muted">انتخاب سیستم از تنظیم مرورگر پیروی می‌کند؛ انتخاب صریح بر آن مقدم است.</p>
  </main>
  <script>
    const root = document.documentElement;
    const select = document.getElementById('theme');
    const media = matchMedia('(prefers-color-scheme: dark)');
    let choice = root.dataset.theme || 'system';
    function normalize(value) { return value === 'light' || value === 'dark' ? value : 'system'; }
    function apply(value) {
      choice = normalize(value);
      if (choice === 'system') delete root.dataset.theme;
      else root.dataset.theme = choice;
      select.value = choice;
      const current = choice === 'system' ? (media.matches ? 'تاریک' : 'روشن') : (choice === 'dark' ? 'تاریک' : 'روشن');
      document.getElementById('theme-status').textContent = `ظاهر فعلی: ${current}`;
    }
    select.addEventListener('change', () => {
      apply(select.value);
      try {
        if (choice === 'system') localStorage.removeItem('example-theme');
        else localStorage.setItem('example-theme', choice);
      } catch {}
    });
    media.addEventListener('change', () => apply(choice));
    window.addEventListener('storage', event => {
      if (event.key === 'example-theme' || event.key === null) apply(event.newValue);
    });
    document.getElementById('demo-form').addEventListener('submit', event => {
      event.preventDefault();
      document.getElementById('form-status').textContent = 'فرم نمایشی دریافت شد؛ داده‌ای ارسال نشده است.';
    });
    apply(choice);
    document.getElementById('theme-control').hidden = false;
  </script>
</body>
</html>

script کوچک داخل head فقط انتخاب ذخیره‌شده معتبر را پیش از رسیدن CSS روی عنصر html می‌گذارد. این ترتیب احتمال نمایش کوتاه Theme اشتباه را کاهش می‌دهد؛ وعده حذف کامل هر نوع تأخیر در همه محیط‌ها نیست. اگر سایت Content Security Policy دارد، مجوز این script باید با nonce یا hash مناسب تنظیم شود. برداشتن محدودیت سیاست برای جا دادن یک نمونه، تصمیم مناسبی برای محصول نیست.

کد اصلی انتخاب را در یک مقدار محدود نگه می‌دارد. هر رشته‌ای که از Storage برسد پذیرفته نمی‌شود. شکست خواندن یا نوشتن Storage هم مانع تغییر فعلی Theme نیست. در محیطی که ذخیره‌سازی محدود است، کاربر هنوز می‌تواند ظاهر همین صفحه را انتخاب کند؛ فقط نباید به او وعده حفظ انتخاب در بازدید بعدی بدهید. این رفتار از وابسته کردن کل رابط به موفقیت localStorage روشن‌تر است.

تغییرهای بعدی را با همان قرارداد دنبال کنید

هنگام تغییر ترجیح سیستم، CSS در حالت system به‌روز می‌شود. برای توضیح وضعیت کنار select، listener مربوط به matchMedia هم متن وضعیت را تازه می‌کند. اگر کاربر حالت روشن یا تاریک را صریح انتخاب کرده باشد، همان listener انتخاب او را تغییر نمی‌دهد. این جدایی جلوی رفتاری را می‌گیرد که تنظیم کاربر با هر تغییر سیستم دوباره بازنویسی شود.

رویداد storage تغییر انتخاب در تب دیگری را دریافت می‌کند. این رویداد در همان تبی که مقدار را نوشته اجرا نمی‌شود، بنابراین update محلی جداگانه لازم است. پاک شدن کلید هم به معنای بازگشت به system است. این انتخاب برای مثال مشخص شده؛ در محصول واقعی می‌توانید قرارداد دیگری داشته باشید، ولی باید برای خواندن اولیه و تغییرهای بعدی یکسان بماند.

در برنامه دارای SSR، سرور معمولاً به localStorage مرورگر دسترسی ندارد. HTML اولیه و مقدار کلاینت ممکن است تفاوت پیدا کنند. ترجیح ذخیره‌شده در Cookie می‌تواند ورودی سرور باشد، اما رفتار cache و حریم خصوصی آن باید در معماری بررسی شود. صرف افزودن یک hook به component، مشکل initial render را برای همه frameworkها حل نمی‌کند. مسئول تعیین Theme اولیه را در یک نقطه مشخص نگه دارید.

حالت تاریک را همراه محتوای واقعی بررسی کنید

جدول طولانی، لینک بازدیدشده، خطای فرم و کنترل disabled را در هر دو Theme ببینید. بررسی صفحه‌ای که فقط یک کارت و عنوان دارد، همه حالت‌های محصول را پوشش نمی‌دهد. اندازه متن و فاصله خطوط را برای جا دادن محتوای بیشتر کم نکنید؛ کاربر باید بتواند صفحه را با zoom هم بخواند. در RTL نیز ترتیب Focus و جهت آیکون‌ها مستقل از Theme باید درست بماند.

تصویر یا محتوای رنگی را با فیلتر سراسری تیره نکنید. کاهش روشنایی همه عکس‌ها می‌تواند جزئیات، لوگو یا اطلاعات نمودار را عوض کند. اگر یک دارایی به نسخه جداگانه نیاز دارد، تصمیم را برای همان دارایی بگیرید. نمونه این مقاله عمداً هیچ دارایی تصویری لازم ندارد؛ قرارداد Theme روی رنگ‌های رابط متمرکز است و محتوای کاربر را خودسرانه تغییر نمی‌دهد.

برای ادامه کار، دسترسی‌پذیری از روز اول به رفتار Focus و ساختار صفحه می‌پردازد و Container Queries مسئله چیدمان component را بررسی می‌کند. Theme و Responsive دو تصمیم جدا هستند که در یک component به هم می‌رسند. وقتی هر دو با نقش و قرارداد روشن تعریف شوند، تغییر رنگ به بازنویسی layout یا رفتار کنترل‌ها تبدیل نمی‌شود.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!