Skip to content

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:

  1. Consult New Nautobot Custom UI APIs, as it contains high-level re-usable custom Nautobot-specific components.
  2. Otherwise, try matching your use case with already existing layouts and components from Bootstrap.
  3. 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.

<div class="card">
    <div class="card-body">
        Content
    </div>
</div>

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":

<div class="card bg-success-subtle border-success">
    <div class="card-body">
        Success
    </div>
</div>

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:

  1. Is the style used in a single or - at most - very few places?
  2. Is the style simple and does not require any combined selectors?
  3. 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:

.custom-size {
    height: 0.625rem; /* 10px */
    width: 1.5rem; /* 24px */
}

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.

document.addEventListener('DOMContentLoaded', () => {
    // Your JavaScript logic goes here.
});

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 djlint includes H013 ("img tag should have an alt attribute") and H016 ("missing title tag"). Do not add either to the ignore list in pyproject.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 context to scan only part of the page, or exclude to leave part of it out. nautobot/core/tests/integration/test_accessibility.py covers 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. See AXE_DISABLED_RULES in nautobot/core/testing/integration.py, and pass disabled_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-hidden hides 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, the hidden attribute and visibility: hidden remove 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.