Styling, Theming and Swizzling

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Docusaurus 3.10.2 and checked on 2026-10-26. A Docusaurus site is a React application whose look comes from a theme. The classic theme, shipped with the classic preset, is built on Infima, a CSS framework that exposes its design decisions as CSS variables. There are three levels of customization, from the least to the most invasive: override variables and add CSS, replace the markup of one theme component by swizzling it, or replace the theme altogether. This page covers the first two levels. Every example was run in a scratch site generated with create-docusaurus@3.10.2 (classic preset, TypeScript, React 19) for the Pocket Units documentation, a tiny units-conversion library written in C# and Python.

The three levels of customization

Level What you change Cost of upgrades

CSS

Variables, class names, custom.css, CSS modules of your own components.

Low. Stable names (variables and theme class names) survive minor releases.

Wrap

Add content around a theme component, or change its props, while the original keeps rendering.

Low to medium. Only the props contract of the component matters.

Eject

Copy the full source of a theme component into the site and rewrite it.

High. Every upgrade may require merging your copy with the new original.

The Docusaurus documentation recommends exhausting the cheaper levels first: CSS is often enough, and wrapping is preferred over ejecting whenever possible. Text labels do not need swizzling at all: running npx docusaurus write-translations -l en writes a code.json file whose messages can be edited.

Style sources in order of priority: Infima, theme layers, plugin layers, custom.css and CSS modules, inline styles

Figure: the sources of style of a Docusaurus page. The data-theme attribute of the html element selects which values of the variables apply.

Global styles with custom.css

The classic preset takes the path of one global stylesheet in its theme.customCss option. A site generated by create-docusaurus already wires it:

docusaurus.config.ts (excerpt)
presets: [
  [
    'classic',
    {
      theme: {
        customCss: './src/css/custom.css',
      },
    } satisfies Preset.Options,
  ],
],

customCss also accepts an array of paths. Everything in the file is global and is loaded after Infima, so a declaration in custom.css overrides Infima without extra specificity (the reason is explained in the section on cascade layers). Two further configuration keys inject CSS without going through the preset:

  • stylesheets is an array of URLs or attribute maps; each entry becomes a link element in the page head, with a stylesheet relation by default. Only href is required in the object form. Use it for fonts or other third-party CSS.

  • clientModules is an array of module paths loaded on every page, and may contain .css files (for example ./mySiteGlobalCss.css) or JavaScript run in the browser.

Infima and CSS variables

Infima is the CSS framework of the classic theme. Its public interface is a set of variables whose names start with --ifm-. Redefining them on :root changes the whole site at once, which is far more robust than overriding the class names that use them. The most important family is the primary color: Infima expects the base color plus six derived shades (dark, darker, darkest, light, lighter, lightest), which the docs suggest generating with a color tool, and recommends a primary color that reaches at least WCAG AA contrast.

Other variables used often in documentation sites include --ifm-code-font-size, --ifm-navbar-height and --ifm-background-surface-color. The classic theme adds a few variables of its own, such as --docusaurus-highlighted-code-line-bg, which sets the background of highlighted code lines. The complete list is in the Infima documentation; this page does not reproduce it.

The following custom.css gives Pocket Units its own palette and defines one variable of the site, --pocket-units-accent, that the examples below reuse:

src/css/custom.css
/* Pocket Units theme: brand colour, dark-mode variant and a few layout tweaks. */
:root {
  --ifm-color-primary: #1b6ca8;
  --ifm-color-primary-dark: #18608f;
  --ifm-color-primary-darker: #175b87;
  --ifm-color-primary-darkest: #134a6f;
  --ifm-color-primary-light: #1e78bb;
  --ifm-color-primary-lighter: #1f7ec2;
  --ifm-color-primary-lightest: #2a92de;
  --ifm-code-font-size: 95%;
  --ifm-navbar-height: 3.75rem;
  --pocket-units-accent: #b45309;
  --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}

[data-theme='dark'] {
  --ifm-color-primary: #6cb6ec;
  --ifm-color-primary-dark: #4aa4e6;
  --ifm-color-primary-darker: #3a9ae3;
  --ifm-color-primary-darkest: #1a7fc8;
  --ifm-color-primary-light: #8ec7f1;
  --ifm-color-primary-lighter: #9ecff3;
  --ifm-color-primary-lightest: #c0e0f8;
  --pocket-units-accent: #f59e0b;
  --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}

/* Theme class name: stable across releases, unlike hashed CSS module names. */
.theme-doc-sidebar-container {
  border-right: 3px solid var(--pocket-units-accent);
}

/* Tables of conversion factors. */
.markdown table th {
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

html[data-navbar='false'] .navbar {
  display: none;
}

The file builds without warnings (npm run build ends with "Generated static files in build"). In the built page the rules above end up in the global stylesheet, and the brand-color values appear unchanged in the generated CSS file.

Which class names to target

The Docusaurus documentation distinguishes three kinds of class names, in decreasing order of stability:

  • Theme class names, such as theme-doc-sidebar-container or docs-doc-page. They are meant to be targeted by site authors and are the first choice.

  • Infima class names (navbar, menu, button--secondary and so on). They are fairly stable but are an implementation detail of the theme, and the documentation discourages overriding them.

  • CSS module class names, which carry a build hash. They may change in any release. If you must reach one, use an attribute selector such as [class*='codeBlockContainer'].

Infima also offers utility classes that can be used in components and in MDX without writing CSS. The footer wrapper shown later uses container, margin-vert--md and text--center, and the built page contains exactly those class names.

Dark mode

The classic theme implements dark mode with an attribute on the root element. The html element carries data-theme="light" or data-theme="dark", and Infima defines separate values for each. To adapt anything to dark mode, either redefine the variables under [data-theme='dark'], as custom.css above does, or prefix a rule with [data-theme='dark'] .your-class. The built page of the scratch site also sets a second attribute, data-theme-choice, that distinguishes an explicit choice (light or dark) from system.

Choosing the initial and allowed modes

The mode is configured in themeConfig.colorMode. The three options and their defaults in @docusaurus/theme-classic 3.10.2 (read from the package source) are:

Option Default Effect

defaultMode

'light'

Mode used when the visitor has made no choice and the system preference is ignored. Accepts 'light' or 'dark'.

disableSwitch

false

Hides the toggle in the navbar, so visitors cannot change the mode.

respectPrefersColorScheme

false

Uses the operating-system preference for visitors without a stored choice. The toggle becomes a three-state cycle: light, dark and system.

The site generated by create-docusaurus sets respectPrefersColorScheme: true. With that option, the inline script that the theme adds to every page chooses the mode in this order: the docusaurus-theme query parameter, the value stored in the browser, then prefers-color-scheme. The documentation recommends the query parameter for embedding a page in a frame with a fixed mode (?docusaurus-theme=dark). Any query parameter named docusaurus-data-<key> is copied to the html element as data-<key>, which is useful for embedded views; the html[data-navbar='false'] rule in custom.css above hides the navbar for ?docusaurus-data-navbar=false.

Where the choice is stored

The choice is saved in localStorage under the key theme. The built page of the scratch site reads the key theme-9f6: the site was generated with future.v4: true, and one of its flags, siteStorageNamespacing, appends a namespace derived from url and baseUrl to every storage key (the storage option of the configuration controls the type and namespace). Enabling that flag on an existing site resets the stored choice of returning visitors, who see the default mode once.

Reading and setting the mode from React

Components that need to know the mode use the useColorMode hook of @docusaurus/theme-common. It returns colorMode (the effective mode), colorModeChoice (the explicit choice, or null for system), setColorMode, isDarkTheme, setLightTheme and setDarkTheme. This component switches the mode, and was used in the scratch site:

src/components/ThemeProbe/index.tsx
import type {ReactNode} from 'react';
import {useColorMode} from '@docusaurus/theme-common';

export default function ThemeProbe(): ReactNode {
  const {colorMode, setColorMode} = useColorMode();
  return (
    <button
      type="button"
      className="button button--secondary button--sm"
      onClick={() => setColorMode(colorMode === 'dark' ? 'light' : 'dark')}>
      Current mode: {colorMode}
    </button>
  );
}

npm run typecheck (tsc) accepted the file. For images that must differ per mode, the theme provides @theme/ThemedImage, which renders one img per mode and hides the wrong one with CSS. In the built scratch page the two images carried the classes themedComponent--light and themedComponent--dark, with generated hash suffixes:

import ThemedImage from '@theme/ThemedImage';
import useBaseUrl from '@docusaurus/useBaseUrl';

<ThemedImage
  alt="Pocket Units logo"
  sources={{
    light: useBaseUrl('/img/logo-light.svg'),
    dark: useBaseUrl('/img/logo-dark.svg'),
  }}
/>

The scratch run used the same file for both modes because the generated site has only one logo; the markup that the build produced is the one described above.

CSS modules

Any file named *.module.css is a CSS module: its class names are rewritten with a hash at build time, so they cannot clash with the theme or with other components, and the import gives an object that maps your names to the generated ones. The scratch site has a UnitBadge component for Pocket Units with its module:

src/components/UnitBadge/index.tsx
import type {ReactNode} from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';

type Props = {
  symbol: string;
  children: ReactNode;
  highlighted?: boolean;
};

export default function UnitBadge({symbol, children, highlighted}: Props): ReactNode {
  return (
    <span className={clsx(styles.badge, highlighted && styles.highlighted)}>
      <code className={styles.symbol}>{symbol}</code> {children}
    </span>
  );
}
src/components/UnitBadge/styles.module.css
.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.1rem 0.6rem;
  border: 1px solid var(--ifm-color-primary);
  border-radius: var(--ifm-global-radius);
  background: var(--ifm-background-surface-color);
}

.symbol {
  color: var(--pocket-units-accent);
}

.highlighted {
  background: var(--ifm-color-primary-lightest);
  color: #111;
}

[data-theme='dark'] .highlighted {
  background: var(--ifm-color-primary-darkest);
  color: #fff;
}

The module uses Infima variables (so it follows the palette) and the data-theme attribute for the dark variant. Used in an MDX document as <UnitBadge symbol="ft" highlighted>foot</UnitBadge>, the build produced <span class="badge_tMJj highlighted_BDXI">, where the suffixes are the hashes. Because the hashes change when the file changes, never copy them into global CSS. A CSS module needs no configuration, and the same mechanism hashes the module CSS of the theme itself, which is the reason the documentation warns against targeting those names.

Two other flavours of CSS are mentioned in the documentation. Sass or SCSS needs the unofficial community package docusaurus-plugin-sass plus sass and an entry in plugins; after that customCss can point to a .scss file and *.module.scss works. In a TypeScript project, add the plugin name to compilerOptions.types of tsconfig.json. This setup was not installed in the scratch site. CSS-in-JS libraries are described by the documentation as work in progress, with possible display quirks, so they are best avoided in a documentation site.

Cascade layers

A recurring problem with a framework stylesheet is specificity: a rule of yours that is shorter than an Infima rule loses. Docusaurus has an opt-in plugin that puts the CSS of the framework in named CSS cascade layers. It is switched on with the future flag future.v4.useCssCascadeLayers, one of the flags that future.v4: true enables together (see Docusaurus Faster and v4 Readiness). The configuration reference says the layers are the ones planned to be applied by default in Docusaurus v4.

The layer names and their order are defined in the source of @docusaurus/plugin-css-cascade-layers, from the lowest priority to the highest:

docusaurus.infima
docusaurus.theme-common
docusaurus.theme-classic
docusaurus.core
docusaurus.plugin-debug
docusaurus.theme-mermaid
docusaurus.theme-live-codeblock
docusaurus.theme-search-algolia.docsearch
docusaurus.theme-search-algolia

A file is assigned to a layer by its path (the plugin matches paths inside node_modules, or in the monorepo of Docusaurus). The CSS of the site, such as custom.css and your own CSS modules, is not on those paths and stays unlayered. In the CSS spec, unlayered declarations beat layered ones whatever their specificity, so site CSS always wins over Infima and the theme. In the built scratch site the stylesheet starts with @layer docusaurus.infima{:root{..., the rule .theme-doc-sidebar-container{border-right... of custom.css is outside any layer, and the stylesheet also declares the plugin layers listed above.

The flag is a small behavioral change, so verify the look of the site after turning it on.

Layout notes: the mobile breakpoint

The classic theme switches between the desktop and the mobile layout at a viewport width of 996 pixels, using plain media queries. The documentation points out that changing that breakpoint in your CSS is not enough: components that call the useWindowSize hook, such as the navbar and the docs sidebar, must be swizzled so that they pass an explicit window size option to it. This is a good example of a customization that CSS alone cannot finish.

Swizzling

Swizzling means copying a component of a theme into the site, in the folder src/theme, so that Docusaurus uses your file in place of the theme file. The theme files are addressed with the alias @theme/ComponentName; Docusaurus resolves the alias to src/theme/ComponentName when it exists and to the theme otherwise. The documentation notes that the command has no internal magic beyond creating those files, so a swizzled component can also be created by hand.

Wrap and eject

There are two ways to swizzle:

  • Wrap creates a component that renders the original one. The original is imported through the alias @theme-original/ComponentName, which refers to the theme version without causing an import loop. You can render content before or after it or change the props. The code to maintain stays small.

  • Eject copies the full source of the original (and of the subcomponents it needs) so you can rewrite it. After an upgrade you must merge any changes of the original yourself. The documentation advises to re-run the eject command after an upgrade, compare with git diff, and keep a comment at the top of the file that lists your changes.

A wrapper imports the original through theme-original and adds content; an ejected file replaces the original entirely

Figure: the two ways to swizzle. A wrapper keeps the original component in use; an ejected copy replaces it.

The swizzle command

npx docusaurus swizzle [themeName] [componentName] [siteDir]

The command is interactive when options are missing: it asks for the theme, the component (typing part of a name filters the list), the action and the language. The options of the CLI in 3.10.2 are:

Option Meaning

--list (-l)

Print the available components and their safety status instead of swizzling. With a theme name it lists only that theme.

--wrap (-w)

Create a wrapper around the original.

--eject (-e)

Copy the original source.

--typescript (-t)

Copy the TypeScript variant of the files when the theme has one.

--javascript (-j)

Copy the JavaScript variant of the files.

--danger

Allow swizzling of unsafe components without the confirmation prompt.

--config <path>

Path of the configuration file when it is not docusaurus.config.js.

With npm the flags of a script must be separated by --, as in the documented example npm run swizzle @docusaurus/theme-classic Footer -- --eject. In the scratch run with npm 11.19.0, the arguments written this way after the component name reached the CLI together with the literal --; the flags were then ignored and the language prompt appeared. Calling npx docusaurus swizzle directly avoids the problem, and all the outputs below were produced that way. After creating or deleting a swizzled file, restart the development server so that Docusaurus detects the change. To remove a swizzle, delete its files under src/theme.

Safe, unsafe and forbidden

Theme authors label each component and each action, and the CLI prints the label. The legend printed by swizzle --list in 3.10.2 says:

Status CLI option Meaning

Safe

Designed to be swizzled. The swizzled component stays compatible with minor upgrades of the theme.

Unsafe

--danger

Allowed but risky: the theme may release breaking changes within a minor version, and you maintain the component.

Forbidden

Not meant to be swizzled; the CLI refuses it.

The status is per action. The listing of @docusaurus/theme-classic 3.10.2 contains 220 entries (the documentation says "about 100"; the number grows with each release). The scratch run counted them by the pair of statuses (wrap and eject):

Wrap / Eject Count Examples

Safe / Safe

40

Footer, ColorModeToggle, SearchBar, NotFound, CodeBlock, DocCardList, MDXContent, the MDXComponents/* and Icon/* entries

Safe / Unsafe

1

DocSidebar

Forbidden / Safe

6

MDXComponents (meant to be ejected), NavbarItem/ComponentTypes, prism-include-languages, Admonition/Types

Forbidden / Forbidden

6

Blog, Blog/Components, Blog/Pages

Forbidden / Unsafe

1

One entry of the listing

Unsafe / Unsafe

166

Layout, Navbar, DocItem, DocRoot, TOC, Tabs, ThemedImage, Heading, BlogPostPage

A practical reading of the table: prefer a safe component, start from the highest-level safe component that reaches your goal, and read the description printed in the listing. The documentation recommends working down the tree of components from the route component (@theme/BlogPostPage for blog posts, for example) and deleting extra swizzled files, so only the necessary ones stay. If the change you want is only possible by swizzling an unsafe component, the CLI output points to a discussion where the use case can be reported to the maintainers.

The footer is a safe component for both actions, so the example wraps it:

npx docusaurus swizzle @docusaurus/theme-classic Footer --wrap --typescript

The command printed:

[SUCCESS]
Created wrapper of Footer from @docusaurus/theme-classic in
- ".../src/theme/Footer/index.tsx"

The generated file only renders the original. Pocket Units adds a line of text above it:

src/theme/Footer/index.tsx
import React, {type ReactNode} from 'react';
import Footer from '@theme-original/Footer';
import type FooterType from '@theme/Footer';
import type {WrapperProps} from '@docusaurus/types';

type Props = WrapperProps<typeof FooterType>;

export default function FooterWrapper(props: Props): ReactNode {
  return (
    <>
      <div className="container margin-vert--md text--center">
        Pocket Units is a demonstration library. Conversion factors are rounded.
      </div>
      <Footer {...props} />
    </>
  );
}

npm run build finished successfully and the sentence appears in the built HTML of every page. The same wrapper pattern fits the documented use case of adding a comment widget below each blog post. Note the typing helper WrapperProps: the wrapper receives the props of the original component, and passing them on with {...props} is what keeps the wrapper compatible when the component gains a prop.

An eject: replacing the 404 page (safe)

NotFound is safe to eject, and the command copies the component together with the subcomponent it depends on:

npx docusaurus swizzle @docusaurus/theme-classic NotFound --eject --typescript
[SUCCESS]
Ejected NotFound from @docusaurus/theme-classic to
- ".../src/theme/NotFound/index.tsx"
- ".../src/theme/NotFound/Content/index.tsx"

Edit the copies to change the markup or the text; the rest of the site continues to use the theme versions of the other components. The ejected file still imports @theme/Layout and @theme/NotFound/Content. If you only need to change the text in NotFound/Content, delete the generated NotFound/index.tsx so that the theme version of that file stays maintained for you.

An unsafe component and the confirmation prompt

Ejecting DocSidebar is unsafe. Without --danger, the CLI prints a warning and asks:

Swizzle action eject is unsafe to perform on DocSidebar.
It is more likely to be affected by breaking changes in the future
If you want to swizzle it, use the `--danger` flag, or confirm that you understand the risks.

? Do you really want to swizzle this unsafe internal component? ›
❯   NO: cancel and stay safe
    YES: I know what I am doing!
    [Exit]

With --danger the ejection happens at once and copies seven files (DocSidebar/index.tsx, Desktop/index.tsx, Desktop/styles.module.css, Mobile/index.tsx and the CollapseButton and Content subcomponents with their modules). That is the cost of ejecting a component with a deep tree: each of those files is now yours. In the same experiment the wrap of DocSidebar (safe) created only DocSidebar/index.tsx, and a later eject of the same component replaced that file without asking, so wrap and eject of one component share the target file.

Forbidden and unknown components

Asking for something the theme forbids gives an error, and a mistyped name gives a suggestion:

$ npx docusaurus swizzle @docusaurus/theme-classic MDXComponents --wrap --typescript
[ERROR]
Swizzle action wrap is forbidden for component MDXComponents

$ npx docusaurus swizzle @docusaurus/theme-classic Nope --eject --typescript
[ERROR] Component Nope not found.
[INFO] Did you mean Logo?

MDXComponents is eject-only because it is the map of components available to every MDX document; its use is described in MDX and React Components.

Components that are not swizzled: Root

Some customizations are done with a file that the CLI does not generate. src/theme/Root.js (or .tsx) is rendered at the very top of the React tree, above the layout, and never unmounts during navigation. The documentation recommends it for state that must survive navigation, or for React context providers. It must be created manually.

Choosing a technique

flowchart TD A["Change to make"] --> B{"Only colors, spacing or fonts?"} B -- yes --> C["Override --ifm-* variables in custom.css"] B -- no --> D{"Only a label text?"} D -- yes --> E["write-translations and edit code.json"] D -- no --> F{"Add or reorder content around a component?"} F -- yes --> G["swizzle --wrap, safe component"] F -- no --> H{"Component is safe to eject?"} H -- yes --> I["swizzle --eject, keep the copy small"] H -- no --> J["Unsafe: --danger and track upgrades"]

Figure: a decision flow from the cheapest to the most expensive technique.

Pitfalls and version notes

  • Hashed class names of CSS modules, including those of the theme, change between releases; target theme class names or variables instead, or use an attribute selector as the last resort.

  • Overriding a variable on :root affects light mode and, unless redefined, dark mode too. Define the dark values under [data-theme='dark'], and check the contrast of the primary color in both modes.

  • A component swizzled from an unsafe entry may stop compiling or behave differently after a minor upgrade. Keep a list of the ejected components of the site in the repository README, and review them with git diff after upgrading.

  • Prefer --wrap for a change of content. Ejecting a component only to add a line of text copies code that the theme would otherwise keep up to date for you.

  • If the stored dark-mode choice appears to reset for returning visitors after a configuration change, check future.v4.siteStorageNamespacing and the storage option, which change the key under which it is saved.

  • With cascade layers on, unlayered rules, such as the ones in custom.css, win over the layered theme rules whatever their specificity, and !important declarations reverse the order of layers, as the CSS specification defines.

  • The development server does not pick up new files in src/theme until it is restarted.

  • The Sass plugin, Tailwind and CSS-in-JS libraries are not part of Docusaurus. The documentation lists only the Sass plugin as an option, and it is community-maintained, so check that it supports the Docusaurus version you use.

  • Search, a component that is commonly swizzled, has its own page: Search.

The browser behavior described for dark mode (the order in which the inline script picks the mode, the query parameters, and the toggle cycle) is taken from the Docusaurus documentation and the built output; the scratch run inspected the generated HTML and CSS, but did not drive a browser to click the toggle.