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.
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.