Dark Mode

Purpose and Scope

Dark mode is Tailwind’s state-aware styling path for applying a different set of utilities when a dark color scheme is active. In author-facing markup, the feature appears as the dark variant, so a component can declare its default appearance and then place dark-mode overrides beside the same element. This page explains what that variant means, how configuration selects the trigger condition, and why dark mode composes with the same variant machinery used by hover, focus, responsive breakpoints, and other conditional utilities.

The implementation covered here is the compatibility plugin that translates the resolved darkMode configuration value into a registered variant. That distinction matters for migrated projects and integrations that still express dark mode through configuration rather than only through CSS-first custom variants. The plugin does not generate a separate dark stylesheet or special color system. It defines the condition for the dark prefix, then leaves utility generation and variant composition to the normal Tailwind compiler pipeline. Sources: packages/tailwindcss/src/compat/dark-mode.ts

Relevant Source Files

  • packages/tailwindcss/src/compat/dark-mode.ts — Defines darkModePlugin, reads the resolved dark-mode configuration, normalizes mode and selector values, validates custom variant formats, and registers the dark variant with the plugin API.

Core Concept: The dark Variant

A variant is a prefix that changes when a utility applies. With dark mode, the underlying utility still describes the CSS declaration, while the prefix describes the activation condition. For example, an element can use a light surface and dark text by default, then add dark prefixed background and text utilities for the alternate state. This keeps the two visual designs colocated in the component markup, which is especially useful when only a few colors, borders, shadows, or decorative details need to change between schemes.

The official documentation presents two common triggering models. The automatic model follows the browser and operating system through the dark color-scheme media feature. The manual model uses a selector placed earlier in the document tree, such as a class on the root element, so application state can control the theme. The compatibility source supports those models through configuration modes, but the template syntax remains stable: authors still write the same dark-prefixed utilities regardless of whether the project uses media queries, a class, or a custom selector. Sources: packages/tailwindcss/src/compat/dark-mode.ts

System-to-Code Mapping

The exported function darkModePlugin receives a narrowed plugin API containing only addVariant and config. It reads darkMode with a fallback of null, then normalizes the result into a mode and a selector. Array configuration is treated as a pair of mode and selector, while non-array configuration is treated as just the mode. When no selector is provided, the compatibility path uses .dark as the default selector. That default is important because selector-based and legacy class-based modes can be expressed compactly.

Once the value is normalized, the plugin maps each supported mode to one variant registration. A media mode registers an at-rule condition using the dark color-scheme media feature. A selector mode registers a low-specificity selector expression using :where, matching the configured selector or descendants under it. A class mode registers the older selector shape with :is, preserving historical behavior for projects that depend on the previous class semantics. A variant mode passes through a validated custom format, allowing advanced selector layouts. Sources: packages/tailwindcss/src/compat/dark-mode.ts

Configuration modeRegistered behaviorPrimary use
nullNo registration in this compatibility pluginNothing is added when configuration does not request a mode.
media@media (prefers-color-scheme: dark)Follow the operating-system or browser color-scheme preference.
selector&:where(selector, selector *)Preferred manual toggling behavior with low selector specificity.
class&:is(selector *)Legacy class-driven behavior retained for compatibility.
variantCustom selector format or formatsAdvanced projects that need to define the placement of the utility selector.

Execution Flow

The execution flow is intentionally small. First, the plugin asks the resolved configuration for the dark-mode setting. Second, it determines whether the setting is a simple mode or a tuple-like array containing both a mode and selector. Third, it performs special handling for custom variant mode, because that path may receive a selector string, an array of selector formats, or a function. Finally, if a valid mode remains, it calls addVariant with the name dark and the format that represents the selected trigger condition. Sources: packages/tailwindcss/src/compat/dark-mode.ts

The custom variant branch includes guardrails because a custom format has to tell the variant engine where the generated utility selector should be inserted. If an array of formats is provided, each string is checked. A format equal to the bare default selector is rejected with a warning, because that does not describe how to combine the condition and the utility. A format without an ampersand placeholder is also rejected. In either invalid case, the mode is disabled, preventing an ambiguous dark variant from being registered. Sources: packages/tailwindcss/src/compat/dark-mode.ts

Authoring and Composition Patterns

For application authors, the preferred pattern is to write the default design first, then add dark-mode overrides only where the visual treatment changes. A card might keep the same spacing, rounding, and layout in both modes while switching surface, text, ring, and shadow colors. Because dark mode is a variant rather than a separate component API, it can be combined with responsive prefixes and interaction states in the same class list. The implementation source supports that mental model by registering one named variant and letting the shared variant engine handle composition.

Manual toggling should be understood as changing the condition that activates the variant, not as changing the utilities themselves. In selector mode, an application can add a theme selector to an ancestor through server rendering, persisted user preference, or client-side state. In media mode, the browser evaluates the user preference and activates matching rules automatically. In both cases, components continue to use the same dark-prefixed utilities. This separation lets teams change the activation strategy without rewriting every component that contains dark-mode styles.

<div class='bg-white text-gray-900 dark:bg-gray-800 dark:text-white'>
  <h3 class='text-base font-medium'>Writes upside-down</h3>
  <p class='text-gray-500 dark:text-gray-400'>
    The Zero Gravity Pen can be used in any orientation.
  </p>
</div>

API and Configuration Reference

The public source entry point on this page is darkModePlugin({ addVariant, config }). It is not typically called directly by application developers; instead, it participates in Tailwind’s compatibility layer when configuration is resolved. Its observable contract is the registration of a variant named dark when a supported mode is present. The only configuration value it reads is darkMode, and the only plugin API mutation it performs is addVariant. That narrow surface makes the behavior easy to reason about during upgrades. Sources: packages/tailwindcss/src/compat/dark-mode.ts

NameKindBehavior
darkModePluginFunctionReads dark-mode configuration and conditionally registers the dark variant.
config('darkMode', null)Configuration readSupplies the mode and optional selector used by the plugin.
addVariant('dark', '@media (prefers-color-scheme: dark)')Variant registrationUsed for operating-system preference based dark mode.
addVariant('dark', '&:where(selector, selector *)')Variant registrationUsed for preferred selector-based manual toggling.
addVariant('dark', selector)Variant registrationUsed for validated custom variant formats.
addVariant('dark', '&:is(selector *)')Variant registrationUsed for legacy class-based compatibility.

Edge Cases and Migration Notes

The main edge cases occur when a project uses custom variant configuration. A selector format that omits the ampersand cannot be safely composed because the compiler cannot know where to place the generated utility selector. A bare default selector is also insufficient in custom variant mode, because it names a condition but does not define the relationship between that condition and the utility. The plugin responds by warning and disabling registration for that invalid custom mode, which is safer than emitting selectors that appear to work only in some layouts. Sources: packages/tailwindcss/src/compat/dark-mode.ts

During migration, distinguish between the newer selector behavior and the older class behavior. Both can support a root-level dark theme toggle, but they generate different selector shapes and may differ in specificity and matching details. If a project is moving toward CSS-first customization, the official docs show overriding the dark variant with a custom variant directive. If a project still relies on configuration compatibility, use the modes described here and test representative components that combine dark mode with responsive and interaction variants.

Next Steps

Use this page when deciding how a project should activate dark styles, when reviewing legacy configuration, or when debugging why a dark-prefixed utility is not taking effect. For adjacent behavior, read hover-focus-and-other-states to understand general state variants, responsive-design to see breakpoint composition, theme to connect dark-mode choices to design tokens, and configuration-and-plugin-api for the plugin API concepts that make addVariant available.