Customizing Swagger UI with CSS and Plugins

Swagger UI gives you two safe extension points — CSS injection and a JavaScript plugin API — so you can theme and extend it without editing swagger-ui-dist source files that a version bump will overwrite. This guide is part of Swagger UI Customization and the broader Developer Portal Frameworks & UI Setup section. It covers scoped CSS, the plugin architecture with wrapActions, exact version pinning, and headless CI snapshot tests.

This matters once a forked or source-edited Swagger UI becomes impossible to upgrade: every security patch reopens merge conflicts. The extension points below let you keep upstream pristine and layer changes on top.

Swagger UI extension points Scoped CSS and a plugin object layer onto unmodified swagger-ui-dist, which renders the OpenAPI spec. scoped CSS .swagger-ui rules plugin object wrapActions swagger-ui-dist unmodified rendered docs from spec

Problem & Context

The broken state is a forked Swagger UI: someone edited swagger-ui.css and a couple of bundled JS files directly to add a brand color and an auth-token interceptor. Now swagger-ui-dist cannot be upgraded without redoing those edits, and a 5.x minor release that refactored internal class names silently broke the custom styles. The page also has no CI check, so the regression shipped unnoticed.

The fix is to revert to an unmodified swagger-ui-dist, move styling into scoped CSS, move behavior into a plugin, pin the version, and add a headless snapshot test that fails the build when the UI stops rendering.

Step-by-Step Solution

1. Pin swagger-ui-dist to an exact version

5.x has shipped breaking changes in minor releases, so pin exactly:

npm install --save-exact [email protected]

2. Inject scoped CSS

SwaggerUIBundle has no css configuration property in the current API, so inject styles with a <style> tag or a linked stylesheet. Scope every rule under .swagger-ui:

// swagger-initializer.js
const ui = SwaggerUIBundle({
  url: '/openapi.json',
  dom_id: '#swagger-ui',
  presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
  layout: 'StandaloneLayout',
  deepLinking: true,
});

// Inject scoped styles after initialization
const style = document.createElement('style');
style.textContent = `
  :root { --primary-color: #0052cc; }
  .swagger-ui .topbar { background-color: var(--primary-color); }
  .swagger-ui .opblock-tag { border-left: 4px solid var(--primary-color); }
`;
document.head.appendChild(style);

Driving colors from a custom property on :root means a re-theme touches one line, and scoping under .swagger-ui stops the rules leaking into the host page.

3. Add behavior through a plugin

Plugins export a plain object with statePlugins, components, or wrapComponents. Use wrapActions to intercept state cleanly instead of patching the DOM:

// auth-interceptor-plugin.js
const AuthInterceptorPlugin = () => ({
  statePlugins: {
    auth: {
      wrapActions: {
        authorize: (originalAction) => (credentials) => {
          // Merge an environment token into every authorization call
          const token = localStorage.getItem('auth_token');
          if (token) {
            return originalAction({
              ...credentials,
              BearerAuth: { value: token, name: 'BearerAuth', schema: { type: 'http', scheme: 'bearer' } },
            });
          }
          return originalAction(credentials);
        },
      },
    },
  },
});

window.ui = SwaggerUIBundle({
  url: '/openapi.json',
  dom_id: '#swagger-ui',
  presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
  plugins: [AuthInterceptorPlugin],   // custom plugins go last
  deepLinking: true,
});

Place custom plugins at the end of the plugins array, after the core presets and any built-ins you need, so the component tree has already resolved.

4. Validate the spec before building

Gate the UI build on a lint pass so a broken spec never renders:

npx @redocly/cli lint openapi.yaml

Expected output ends with No errors or warnings. when the spec is clean. For organizational rules on top of the OpenAPI rules, add npx @stoplight/spectral-cli lint openapi.yaml.

5. Snapshot test in CI

Install Playwright and run headless tests against the served UI:

npm install --save-dev @playwright/test @redocly/cli
# .github/workflows/docs.yml
- name: Validate OpenAPI Spec
  run: npx @redocly/cli lint openapi.yaml
- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium
- name: Run UI snapshot tests
  run: npx playwright test --reporter=github
  env:
    CI: true

Complete Working Example

A self-contained Playwright test plus the bundle step it depends on. Bundle the spec so the test serves a single local file and never hits an external URL that a CI runner cannot reach:

// tests/swagger-ui.spec.js
import { test, expect } from '@playwright/test';

test('Swagger UI renders operations', async ({ page }) => {
  await page.goto('http://localhost:3000/docs');
  // Title block confirms the spec loaded
  await expect(page.locator('.swagger-ui .info .title')).toBeVisible();
  // At least one operation block must render
  const ops = page.locator('.swagger-ui .opblock');
  await expect.poll(() => ops.count()).toBeGreaterThan(0);
});

Produce the local spec the dev server serves with:

npx @redocly/cli bundle openapi.yaml --output dist/openapi-bundled.json

Serve dist/openapi-bundled.json from the same process as the Swagger UI page so headless runs have no 404 asset fetches.

Gotchas & Edge Cases

  • CSS specificity conflicts on 5.x minor updates. Direct element targeting collides with internal class refactors. Lower specificity with :where(), or prefer custom properties on :root referenced from .swagger-ui rules so a refactor cannot break the cascade.
  • Plugin load order causes initialization errors. Registering a custom plugin before SwaggerUIBundle has resolved its presets triggers state-init failures. Always append custom plugins last, after built-ins such as SwaggerUIBundle.plugins.DownloadUrl.
  • CI fails on unresolved static assets. Headless runners have no network path to an external spec URL. Bundle the spec with @redocly/cli bundle and serve it from the same process as the page during tests.

Reach for configuration before either CSS or a plugin. A surprising share of customization requests are already supported options — layout, expansion depth, whether the try-it panel exists, operation sorting, which snippet languages appear, whether credentials persist. Reading the full option list once removes several items from a backlog and, unlike the alternatives, every one of those settings is a maintained interface that survives upgrades.

Use plugins for behaviour and stylesheets for appearance. The two tools solve different problems and swapping them is where fragility comes from. A plugin that wraps a documented component to add an annotation, or intercepts a request before it is sent, is working with the extension points the project provides. A plugin that reaches into internal state to change how something looks is as brittle as a class-name selector and much harder to debug when it breaks.

Tool per kind of change Configuration for supported behaviour, plugins for new behaviour at defined extension points, and stylesheets for appearance only. configuration supported behaviour always try this first plugins new behaviour at defined extension points stylesheet appearance only keep the selector set small

Nothing here hides an operation. It is worth repeating because the request arrives constantly: a rule that hides an operation removes it from the rendered page and leaves it in the document the browser already downloaded. Anyone can read it in the network tab or by requesting the specification URL. Filtering the document before it is published is the only approach that does what people mean.

Styling cannot remove content A hidden operation remains in the downloaded specification, so removal has to happen before the document is published. display:none page only still in the JSON network tab, spec URL filter at build time actually removed

FAQ

How do I prevent custom CSS from breaking during Swagger UI updates?

Scope every selector under .swagger-ui, drive colors and typography from CSS custom properties, and avoid targeting internal class names that begin with opblock- or model-. Those internal names change frequently between minor versions, so anything keyed to them is the first thing to break on an upgrade.

Can I inject React components into Swagger UI without forking the repository?

Yes. Export components and wrapComponents from a plugin object and pass the plugin through the plugins array. Swagger UI merges the custom component tree at initialization time, so you extend the UI without forking and keep swagger-ui-dist upgradeable.

What spec validation step should run before UI generation?

Run npx @redocly/cli lint openapi.yaml for OpenAPI-rule validation and npx @stoplight/spectral-cli lint openapi.yaml for custom organizational rules. Both exit with a non-zero code on errors, which makes them safe CI gates that block a bad spec from ever reaching the rendered UI.

A last piece of discipline that pays for itself: keep every override in one file, and put a comment above each rule naming what it changes and why configuration was not sufficient. On the next upgrade that file is a checklist rather than an archaeology exercise, and rules whose justification no longer holds can be deleted with confidence instead of being carried forward indefinitely because nobody remembers what they were for.

Treat the size of that file as a signal in its own right. A short one describes a renderer being configured; a long one describes a renderer being fought, and the usual remedy is not more CSS but a different renderer, or a specification change that removes the need for the customization entirely.

Reviewing that file on each renderer upgrade takes an hour and is the single habit that determines whether a customised deployment stays upgradeable. Skipping it a few times in a row is how a portal ends up pinned to a version nobody is willing to move off, which is a far more expensive position than any individual customization was worth.

The hour spent reviewing is always cheaper than the quarter spent migrating off a version that can no longer be upgraded safely.

That review is also the natural moment to check whether a customization has since become a supported configuration option, which happens more often than most teams expect across a year of releases.

Deleting an override that a supported option now covers is the most satisfying line of the review, and there is usually at least one.