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.
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:rootreferenced from.swagger-uirules so a refactor cannot break the cascade. - Plugin load order causes initialization errors. Registering a custom plugin before
SwaggerUIBundlehas resolved its presets triggers state-init failures. Always append custom plugins last, after built-ins such asSwaggerUIBundle.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 bundleand 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.
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.
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.
Related
- Swagger UI Customization
- Migrating from Swagger UI to Scalar for Better DX
- Configuring Redoc for Enterprise API Documentation
- Developer Portal Frameworks & UI Setup
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.