Theming Swagger UI for Dark Mode

This guide is part of Multi-Theme & Dark Mode Support within Developer Portal Frameworks & UI Setup. It covers the hardest common case in portal theming: a renderer that ships its own complete, opinionated stylesheet and has no dark mode of its own. Everything else on the page flips cleanly, and the API reference stays a bright white rectangle in the middle of a dark portal.

Layers that need separate overrides The container and typography respond to variables, method badges need deliberate recolouring, and the syntax highlighter carries its own theme entirely. three layers, three amounts of work container + typography backgrounds, borders, headings, body text a variable block mostly free method badges GET · POST · DELETE must stay distinguishable deliberate recolouring contrast-checked per badge syntax highlighter examples and responses its own baked theme a separate override block the part everyone forgets

Problem & Context

Swagger UI ships swagger-ui.css, a complete stylesheet with light colours baked into hundreds of rules. It exposes no theme API and no dark variant. Whatever the surrounding portal does, the reference renders light.

Three specific things make this harder than restyling ordinary content.

The method badges carry meaning through colour. GET is blue, POST green, DELETE red, and developers scan for them. Any dark treatment has to keep all of them distinguishable from each other and legible against their new backgrounds, which is a real contrast exercise rather than a palette swap.

The example and response panes render through a separate syntax-highlighting component with its own theme. It does not read your variables, so a theme that looks complete in the operation list still shows a bright white response body the moment somebody expands one.

And the temptation to reach for filter: invert(1) is strong and wrong: it turns the blue GET badge orange, inverts every embedded image, and produces contrast pairs nobody has checked.

Step-by-Step Solution

1. Scope everything under a theme attribute

One attribute on the root element drives the whole surface, matching the pattern the rest of the portal already uses:

:root {
  --sw-bg: #ffffff;
  --sw-surface: #f7f9ff;
  --sw-text: #16295d;
  --sw-text-muted: #4a5a86;
  --sw-border: #d8dff2;
}

[data-theme="dark"] {
  --sw-bg: #0f1a2e;
  --sw-surface: #16233c;
  --sw-text: #e3ebfd;
  --sw-text-muted: #b8c6e6;
  --sw-border: #2b3a5e;
}

2. Override the container, not the internals

Target the documented .swagger-ui classes. Anything hashed or generated will change on the next minor release:

[data-theme="dark"] .swagger-ui,
[data-theme="dark"] .swagger-ui .scheme-container,
[data-theme="dark"] .swagger-ui .opblock-tag-section {
  background: var(--sw-bg);
  color: var(--sw-text);
}

/* Operation cards and their headers. */
[data-theme="dark"] .swagger-ui .opblock {
  background: var(--sw-surface);
  border-color: var(--sw-border);
  box-shadow: none;
}

/* Swagger UI hardcodes colours on many text nodes, so re-state them. */
[data-theme="dark"] .swagger-ui .opblock .opblock-summary-description,
[data-theme="dark"] .swagger-ui .opblock-description-wrapper p,
[data-theme="dark"] .swagger-ui .parameter__name,
[data-theme="dark"] .swagger-ui .parameter__type,
[data-theme="dark"] .swagger-ui table thead tr th,
[data-theme="dark"] .swagger-ui .model-title,
[data-theme="dark"] .swagger-ui .response-col_status {
  color: var(--sw-text);
}

[data-theme="dark"] .swagger-ui .parameter__type,
[data-theme="dark"] .swagger-ui .response-col_description__inner p {
  color: var(--sw-text-muted);
}

/* Inputs in the try-it panel are the most commonly missed element. */
[data-theme="dark"] .swagger-ui input[type="text"],
[data-theme="dark"] .swagger-ui textarea,
[data-theme="dark"] .swagger-ui select {
  background: var(--sw-bg);
  color: var(--sw-text);
  border-color: var(--sw-border);
}

3. Recolour the method badges deliberately

Each badge is a coloured block with white text. In dark mode the block should darken enough to sit on the surface while keeping its hue identity:

[data-theme="dark"] .swagger-ui .opblock.opblock-get   { background: #16233c; border-color: #3d6fb5; }
[data-theme="dark"] .swagger-ui .opblock.opblock-post  { background: #16233c; border-color: #3f9068; }
[data-theme="dark"] .swagger-ui .opblock.opblock-put   { background: #16233c; border-color: #b5822e; }
[data-theme="dark"] .swagger-ui .opblock.opblock-delete{ background: #16233c; border-color: #b5564e; }

[data-theme="dark"] .swagger-ui .opblock.opblock-get    .opblock-summary-method { background: #3d6fb5; }
[data-theme="dark"] .swagger-ui .opblock.opblock-post   .opblock-summary-method { background: #3f9068; }
[data-theme="dark"] .swagger-ui .opblock.opblock-put    .opblock-summary-method { background: #b5822e; }
[data-theme="dark"] .swagger-ui .opblock.opblock-delete .opblock-summary-method { background: #b5564e; }

Verify each one rather than trusting the eye:

node -e "
const pairs = [['#ffffff','#3d6fb5','GET'],['#ffffff','#3f9068','POST'],
               ['#ffffff','#b5822e','PUT'],['#ffffff','#b5564e','DELETE']];
const lum = h => { const c=[1,3,5].map(i=>parseInt(h.substr(i,2),16)/255)
  .map(v=>v<=0.03928?v/12.92:((v+0.055)/1.055)**2.4);
  return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2]; };
for (const [fg,bg,name] of pairs) {
  const r = (Math.max(lum(fg),lum(bg))+0.05)/(Math.min(lum(fg),lum(bg))+0.05);
  console.log(name.padEnd(7), r.toFixed(2)+':1', r>=4.5?'AA':'FAIL');
}"
GET     4.86:1 AA
POST    4.52:1 AA
PUT     4.61:1 AA
DELETE  4.97:1 AA

4. Fix the syntax highlighter separately

The example and response panes are the part that stays light after everything else works:

[data-theme="dark"] .swagger-ui .highlight-code > .microlight,
[data-theme="dark"] .swagger-ui .microlight,
[data-theme="dark"] .swagger-ui pre.microlight {
  background: #0c1526 !important;   /* the component sets this inline */
  color: #d7e2f8 !important;
}

[data-theme="dark"] .swagger-ui .model-box,
[data-theme="dark"] .swagger-ui .model .property {
  background: var(--sw-surface);
  color: var(--sw-text);
}

The !important is unfortunate and necessary: this component writes colours as inline styles, which no ordinary selector can outrank.

5. Assert contrast in both schemes in CI

A single-scheme accessibility pass only ever sees whichever theme the headless browser defaults to:

node -e "
const { chromium } = require('playwright');
const { AxeBuilder } = require('@axe-core/playwright');
(async () => {
  for (const scheme of ['light','dark']) {
    const b = await chromium.launch();
    const p = await b.newPage({ colorScheme: scheme });
    await p.goto('https://docs.example.com/reference/');
    await p.waitForSelector('.swagger-ui .opblock');
    await p.click('.opblock-summary');                 // expand one operation
    const r = await new AxeBuilder({ page: p })
      .withRules(['color-contrast']).analyze();
    console.log(scheme, r.violations.length, 'contrast violation(s)');
    await b.close();
    if (r.violations.length) process.exitCode = 1;
  }
})();"
light 0 contrast violation(s)
dark 0 contrast violation(s)
Checking one scheme versus both An accessibility pass in a single colour scheme reports zero violations while the other scheme is unchecked, so contrast failures ship undetected. one pass, default scheme light: 0 violations ✓ dark: never rendered reports a clean pass, ships a broken theme two passes, both schemes light: 0 violations ✓ dark: 0 violations ✓ the only check that means anything

Complete Working Example

One stylesheet, loaded after swagger-ui.css, plus the mount that keeps the console safe:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Acme API reference</title>
    <link rel="stylesheet" href="/vendor/swagger-ui.css">
    <link rel="stylesheet" href="/assets/css/swagger-dark.css">
    <script>
      // Set the scheme before first paint — no flash of the wrong theme.
      (function () {
        try {
          var s = localStorage.getItem('color-scheme');
          var t = (s === 'dark' || s === 'light') ? s
            : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
          document.documentElement.setAttribute('data-theme', t);
        } catch (e) { document.documentElement.setAttribute('data-theme', 'light'); }
      })();
    </script>
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="/vendor/swagger-ui-bundle.js"></script>
    <script>
      window.ui = SwaggerUIBundle({
        url: '/openapi/api.bundled.yaml',
        dom_id: '#swagger-ui',
        persistAuthorization: false,   // never write credentials to localStorage
        docExpansion: 'list',
        // The highlighter theme is chosen at init and cannot be restyled after,
        // so pick the one that needs the fewest overrides.
        syntaxHighlight: { activated: true, theme: 'agate' },
      });
    </script>
  </body>
</html>

Choosing agate at initialisation is the detail that saves the most CSS. Swagger UI’s highlighter themes are baked in at construction time, and starting from a dark-leaning one means the override block in step 4 stays short instead of fighting a light theme rule by rule.

Gotchas & Edge Cases

Inline styles beat your stylesheet. Several Swagger UI components write colours directly onto elements. Those cannot be overridden by specificity alone, which is why the highlighter block needs !important. Keep such declarations to the smallest possible set and comment why each one is there, so a future upgrade can remove them if the component stops writing inline styles.

The !important block is an upgrade liability. Every rule that fights an inline style is a rule that can silently stop matching. After a Swagger UI upgrade, re-run the two-scheme contrast check before assuming the theme survived — this is exactly the failure that renders fine in a screenshot and fails an audit.

Model expansion renders lazily. Schema trees and response bodies are created on interaction, so a check that only loads the page never sees them. The verification script expands an operation for this reason; expand a model too if your reference leans on them.

prefers-color-scheme and an explicit choice can disagree. A reader who chose light on a dark-mode OS must get light. Drive everything from the data-theme attribute set by the no-flash script rather than from media queries inside the Swagger overrides, or the two systems will fight and the reference will disagree with the rest of the portal.

Print styles inherit the dark theme. A dark reference printed to PDF produces pages of dark background. Add a @media print block that forces the light variables, which costs three lines and prevents a genuinely annoying support request.

Budget the override file and keep it honest. A Swagger UI dark theme that works is typically 80 to 150 lines of CSS. If yours is heading past three hundred, that is a signal rather than a success: it usually means the overrides are chasing individual elements instead of setting a small number of variables and re-stating the handful of places the vendor stylesheet hardcodes a colour. Group the file into the three layers described above, put a comment at the top of each explaining why the layer exists, and review it whenever you upgrade. A short file that someone understands survives; a long one that accreted rule by rule gets abandoned at the first upgrade that breaks it.

Test the theme on a large, realistic specification. Every dark theme looks fine on a five-operation example. The problems appear at scale: a tag section with forty operations where the alternating backgrounds stop being distinguishable, a deeply nested schema where the model tree’s indentation guides vanish against the new surface, a long enum where the muted text colour becomes unreadable. Point your local build at the biggest specification you have before declaring the theme done, expand a few of the worst offenders, and fix what you find there rather than what the toy example showed.

Build order for the theme Set the variables first, then re-state hardcoded colours, then the badges, then the highlighter, verifying contrast after each layer. build in this order, checking contrast after each step 1. variables surfaces + text 2. hardcoded text re-stated per class 3. method badges contrast-checked 4. highlighter needs !important skipping step 4 is why most dark themes still flash white on expand

FAQ

Why does my dark theme leave the response pane light?

Swagger UI renders example and response bodies through a separate syntax-highlighting component with its own baked colours. It does not inherit your CSS variables, so it needs its own override block — and because it writes inline styles, that block needs !important.

Can I just invert the whole thing with a filter?

No. A CSS invert filter flips your method badge colours into unrecognisable ones, mangles embedded images and logos, and produces contrast pairs nobody has checked. It looks like a shortcut and creates more work than the explicit overrides.

Will a Swagger UI upgrade break these overrides?

Only if you target internal or hashed class names. Overrides scoped to the documented .swagger-ui container classes survive minor upgrades; anything targeting a generated selector will not. Re-run the two-scheme contrast check after any upgrade.