UI Best Practices¶
Whether you're unsure of where to start the implementation of your UI, or just curious about the best possible approach you could take, this guide is to answer the questions you may have.
To browse Nautobot example code snippets and their representations see Previewing the theme.
Note
These are roughly outlined general guides, and it is impossible for them to cover every scenario. Always use your best judgement in a specific situation.
Structure¶
Throughout the UI composition process, always work in this order:
- Consult New Nautobot Custom UI APIs, as it contains high-level re-usable custom Nautobot-specific components.
- Otherwise, try matching your use case with already existing layouts and components from Bootstrap.
- If none the above are sufficient, we recommend working your way through combining Style and Behavior guides below.
Note
This step may be difficult at times, but it is worth every effort. Choosing right building blocks for bigger UI structures is arguably the most important part of their implementation, as it is crucial for the end-product usability and maintainability.
To get a better picture of why putting effort into finding the best matching existing components is beneficial for you, let's take a look at examples below:
<div style="background-color: #ffffff; border: 1px solid #dedede; border-radius: 4px; color: #1a1a1a; display: flex; flex-direction: column; overflow: hidden; position: relative; word-wrap: break-word;">
<div style="flex: 1 1 auto; padding-block: 8px; padding-inline: 10px;">
Content
</div>
</div>
The component presented above is - to simply put it - an antipattern. Although initial effort of writing it from scratch, or copying it over from somewhere else, is low, other than that it has virtually no advantages. It uses basic style HTML attributes and hardcoded color and pixel values. Not only is it difficult to decipher its purpose, but it is also prone to all sorts of errors.
<div class="bg-body border d-flex flex-column overflow-hidden position-relative rounded text-break text-body">
<div class="flex-grow px-8 py-10">
Content
</div>
</div>
The above component is generally well-built, with Bootstrap helpers and utilities used for proper styling. With some slight differences this could be an actual go-to implementation for a hypothetical component. But in this specific case we can do better. Let's see the last example.
This is the exact same component as the previous ones, but instead of building styles up from the bottom it re-uses basic Bootstrap Card component, and require no further styling. Real world scenarios may not be as simple as this, but it servers as a good example of the idea that's being laid out here.
Style¶
Style overrides¶
As mentioned, it is recommended to use off-the-shelf components and elements, but sometimes they may require various style adjustments to meet specific requirements, be it a custom color, size, spacing, etc.
Bootstrap offers a broad range of re-usable styles, from single-style Helpers and Utilities to ready-to-use layouts and components. On top of that, Nautobot also provides its own extensions documented in New Nautobot Custom UI APIs.
When styling elements, we recommend starting with base high-level (component) classes and, if needed, override their specific styles with Bootstrap or Nautobot helpers and utilities.
To expand on the previous Card component example, let's say we need to render a card with green background and border, i.e. a "success card":
Custom styles¶
In case available helpers and utilities are not sufficient to style a component, there are two possible ways to approach this problem. To find the more suitable out of them depends on a particular use case. Let's answer these questions first:
- Is the style used in a single or - at most - very few places?
- Is the style simple and does not require any combined selectors?
- If the style involves a custom defined color - should the color be the same for application's light and dark theme?
If the answer to all these questions is yes, then we advise using HTML style attribute for simplicity as well as coupling the style tightly with a specific element.
Otherwise, define a custom CSS code within <style> tag in document head, preferably inside a {% block extra_styles %} Django template block. If developing for the core and custom styles are intended to be used throughout multiple pages, consider adding them to the main nautobot.scss file.
Custom colors¶
It is a good practice to define custom colors for both light and dark themes of the application. For example:
/* CSS */
.custom-color {
--custom-color: #000000;
color: var(--custom-color);
}
[data-bs-theme="dark"] .custom-color {
--custom-color: #ffffff;
}
Or in SCSS:
/* SCSS */
.custom-color {
--custom-color: #000000;
color: var(--custom-color);
}
@include color-mode(dark, true) {
.custom-color {
--custom-color: #ffffff;
}
}
Length units¶
It is a good practice to use length values expressed in rem instead of px units. As opposed to absolute pixels, rem values scale in relation to the root font size, making the application more accessible. By default, 1rem is equal to 16px. For example:
Buttons and other inline elements whitespace¶
Buttons and other inline elements by default preserve whitespace between its child nodes. When there are multiple children, most of the time these whitespaces are not desirable due to their somewhat uncontrolled nature. We recommend using explicit gaps instead, while removing the whitespace:
<a href="{{ return_url }}" class="btn btn-secondary">
<span aria-hidden="true" class="mdi mdi-close me-4"></span><!--
-->Cancel
</a>
In this example we removed whitespace between the nodes with HTML comment (<!-- -->) and used margin (me-4) to create a gap.
Note
This rule does not apply to inline-flex elements, as they render their child nodes without whitespaces in between.
Behavior¶
Some UI parts require special behaviors ouf of scope of available components or basic web platform capabilities. To achieve this, implementing JavaScript logic is necessary. We recommend writing JavaScript code that is specific to given page within <script> tag, preferably inside a {% block javascript %} Django template block. When a script is intended to be used on multiple pages, or globally, we recommend creating a separate .js file and importing it appropriately.
Warning
Unless necessary, we advise against introducing any lower density of scripting than recommended above, that is for example inside templates that can be included multiple times on a single page, which would in turn execute a single script more than once and potentially lead to errors.
Functional JavaScript and immutability¶
It is a good practice to follow functional JavaScript rules with immutability principle whenever possible, to create easily understandable code with fewer errors. Here's an example in which we compare two approaches:
/* Non-functional, mutable */
const elements = document.querySelectorAll('.example');
const visibleElements = [];
for (element of elements) {
const isVisible = window.getComputedStyle(element).display !== 'none';
if (isVisible) {
visibleElements.push(element);
}
}
/* Functional, immutable */
const elements = document.querySelectorAll('.example');
const visibleElements = [...elements].filter((element) => window.getComputedStyle(element).display !== 'none');
Document DOM Content Loaded event¶
When implementing your own custom JavaScript logic, our recommendation is to wrap it with document DOMContentLoaded event handler. This makes sure that the script is executed only after the page DOM has been fully loaded, and all core scripts have been run.
If parts of the DOM are loaded or reloaded via HTMX, and need custom JavaScript to set them up after they are retrieved, you may want to reuse the same logic as a function linked to the htmx.onLoad() event handler, for example:
document.addEventListener('DOMContentLoaded', () => {
doTheThing(document);
});
htmx.onLoad((content) => {
doTheThing(content);
});
jQuery deprecation¶
As of Nautobot 3.0, any jQuery usage is deprecated. There are other libraries still in use that depend on it (like Select2), but unless absolutely necessary, vanilla JavaScript should be used instead.
Previewing the theme¶
When settings.DEBUG is set to True, an authenticated Nautobot user can access the URL /theme-preview/ to retrieve a templated view that showcases many of the different Nautobot UI elements. While not necessarily comprehensive, this view is designed to provide an overview of the current theme more conveniently than clicking around to various specific pages in the UI. Feel free to add more example content into this view as needed.
Accessibility¶
Nautobot targets WCAG 2.2 Level AA. The conventions below are the ones core already follows; new UI code and Nautobot App code should follow them too.
Automated checking¶
Two linters enforce a subset of this automatically, so run them before opening a PR:
invoke djlintincludesH013("img tag should have an alt attribute") andH016("missing title tag"). Do not add either to the ignore list inpyproject.toml.-
Integration tests can assert against axe-core via
SeleniumTestCase.assertNoAccessibilityViolations(), which scans the page currently loaded in the browser against the WCAG 2.2 A and AA rule tags (which include everything carried forward from 2.0 and 2.1):def test_my_view(self): self.browser.visit(f"{self.live_server_url}/plugins/my-app/things/") self.assertTrue(self.browser.is_element_present_by_tag("main", wait_time=10)) self.assertNoAccessibilityViolations()It gates on findings at every impact level, because impact describes how badly a violation affects a user rather than how important the success criterion is. Pass
contextto scan only part of the page, orexcludeto leave part of it out.nautobot/core/tests/integration/test_accessibility.pycovers the shared page templates.One rule is currently switched off:
color-contrast(WCAG 1.4.3). The theme fails it in a handful of places, and correcting that means retuning palette tokens, which is a deferred product decision. Contrast is therefore not checked automatically anywhere. SeeAXE_DISABLED_RULESinnautobot/core/testing/integration.py, and passdisabled_rules=()to scan with it on.
Also read axe-core's incomplete results, which are checks it could not decide rather than passes. They are not gated
on -- they need a human -- but they are where a real defect hides when it produces no violation. A label overflowing its
container reports only as "background color could not be determined", because contrast is indeterminate for whatever
part of an element falls outside the ancestor painting its background.
The assertion above does not report them; it fails on violations alone. Read them from a scan by hand, below. There is
no axe in the browser console to call directly -- axe-core is a build-time dependency and is not bundled into the app,
which is why the test helper injects it into the page itself.
Neither linter is a substitute for keyboard-testing a new component: tab through it, operate it with Enter, Space, the arrow keys and Escape, and confirm focus is always visible and never trapped.
Scanning by hand¶
Browser extensions such as Accessibility Insights or axe DevTools inspect the live DOM, which the automated tests deliberately cannot substitute for -- they catch anything a runtime class or a piece of JavaScript introduces after render.
Do it against a server with DEBUG off, or expect noise that is not yours. The development environment runs with
DEBUG = True, which enables Django Debug Toolbar, and the toolbar injects a #djDebugRoot element that reports a
color-contrast finding on its own collapsed handle. It is a false positive twice over: the handle measures 5.27:1 in
light mode and 7.01:1 in dark once its opacity: 0.6 is composited (18.33:1 as declared), and the toolbar is a dev-only
dependency that is never installed for a Nautobot user. assertNoAccessibilityViolations() excludes it via
AXE_EXCLUDE_SELECTORS; a browser extension will not, so set NAUTOBOT_DEBUG=False for the run.
That exclusion list is for third-party developer tooling only. Reach for it when something injected into the page is not part of what Nautobot ships -- never to quiet a finding in our own markup.
Accessible names¶
Every interactive element needs a name. An icon on its own is not a name, and neither is a tooltip -- title and
data-bs-title are unreliable and are not exposed consistently.
For an icon-only control, hide the icon and add visually hidden text:
<button class="btn btn-primary" type="button">
<span aria-hidden="true" class="mdi mdi-plus-thick"></span>
<span class="visually-hidden">Add a new device</span>
</button>
Mark every purely decorative mdi glyph aria-hidden="true" so it is not announced alongside adjacent text. Decorative
images take alt="" -- an empty alt, not a missing one, and not role="presentation" alongside alt text, since the
two contradict each other.
Hiding things¶
The two mechanisms are not interchangeable:
visually-hiddenhides content visually but keeps it in the accessibility tree and the tab order. Use it for text that should be announced but not seen.d-none, thehiddenattribute andvisibility: hiddenremove content from both. Use these to hide UI that should be unavailable.
Using visually-hidden to hide a control leaves keyboard users tabbing into something they cannot see.
Forms¶
Every field needs a label associated with for="{{ field.id_for_label }}". When the design calls for no visible
label, emit a visually hidden one rather than none.
Django already emits required, aria-invalid="true" on error, and
aria-describedby="<auto_id>_helptext <auto_id>_error" on the widget. Custom form templates must render the matching
id="{{ field.auto_id }}_helptext" and id="{{ field.auto_id }}_error" attributes, otherwise the reference dangles and
neither the help text nor the errors are announced. Prefer {% render_field %}, which handles this.
Tables¶
th elements need scope="col". Sortable columns need aria-sort reflecting the current state, since a sort arrow icon
alone conveys nothing non-visually. Tables need an accessible name; inc/table.html supplies a visually hidden
<caption>, overridable with a table_caption context variable.
Dialogs¶
A modal needs role="dialog", aria-modal="true" and aria-labelledby pointing at its title's id. For a dialog whose
content is swapped in by HTMX, keep the title's id fixed so it always matches the dialog's aria-labelledby -- see the
components/htmx/object_embedded_* partials, whose headings carry the id that #embedded_action_modal points at.
Colour¶
When a colour is user-supplied, derive its text colour with the fgcolor template filter, which picks black or white by
actual WCAG contrast ratio.
Colour must never be the only signal. Text-coloured links inside a block of prose also need an underline (WCAG 1.4.1).
Dynamic content¶
Content that appears without a page load is not announced unless it lands in a live region. #header_messages is a
persistent aria-live="polite" region, so anything appended to it is announced. For a component that updates in place,
add a role="status" element, as the paginator does for its "Showing X-Y of Z" range.