jui-omnibar
v5.1.1
Published
Juniper omnibar web components
Readme
JUI Omnibar
A lightweight, framework-agnostic web component omnibar for Juniper products.
Includes:
- Product switcher with a redirect modal that opens products in the same tab
- Profile switcher with a redirect modal on the "Profile" link that opens Portal in a new tab
- Image-based icons with automatic light/dark switching
- Account-aware configuration
- TypeScript definitions
- Fully accessible popovers & keyboard support
- No framework dependencies
Table of contents
- Installation
- Usage
- Icons
- Configuration
- Data tags
- Attributes
- Events
- Dark mode
- Styling
- Loading strategy (avoiding layout jank)
- Package structure
- Development
- Accessibility
- Security
- Migration from v3.0.x
- Migration from v3.x (v4.0.0)
- Migration from v4.x (v5.0.0)
- License
Installation
NPM
npm install jui-omnibarimport "jui-omnibar/omnibar.css";
import "jui-omnibar";CDN (Quick start)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/omnibar.css" />
<script type="module">
import "https://cdn.jsdelivr.net/npm/[email protected]/dist/omnibar.js";
</script>Pin to a specific version in production rather than using
latest.
Usage
The tag itself, with its account attributes:
<jui-omnibar product="MIS" account="Highlands Academy" account-type="school" home="/"></jui-omnibar>const omnibar = document.querySelector("jui-omnibar");End-to-end example
Attributes only cover the product/account name shown in the bar itself — the product switcher, profile switcher, and nav icons are configured imperatively via JS properties. Here's everything wired together in one minimal page; see Configuration and Nav action icons below for the full option set each one supports.
<jui-omnibar
id="omnibar"
product="MIS"
account="Highlands Academy"
account-type="school"
home="/"
></jui-omnibar>const omnibar = document.getElementById("omnibar");
omnibar.productSwitcher = {
current_product: "MIS",
products: [
{ id: "mis", title: "MIS", href: "/mis", active: true },
{ id: "comms-hub", title: "Comms Hub", href: "/comms-hub" },
],
};
omnibar.profileSwitcher = {
user_name: "Joe Bloggs",
account: "Highlands Academy",
account_type: "school",
logout: () => {
window.location.href = "/logout";
},
};
// Nav icons are all optional — omit any of these to hide them
omnibar.help = "/help";
omnibar.inbox = "/inbox";
omnibar.inboxCount = 4;Icons
All icons are provided as image URLs — the component no longer accepts inline SVG strings. Supply a light-mode URL and an optional dark-mode URL. The correct variant is shown automatically when data-theme="dark" is set on <body>.
{
icon: "/icons/my-icon.svg", // shown in light mode
icon_dark: "/icons/my-icon-dark.svg", // shown in dark mode (optional)
}If icon_dark is omitted, the single icon image is used in both modes.
Bundled icons
The package ships a set of icons at dist/icons/ (also available on the CDN). These are used internally and can be referenced directly if needed:
dist/icons/
help.svg / help-dark.svg
products.svg / products-dark.svg
menu.svg / menu-dark.svg
search.svg / search-dark.svg
inbox.svg / inbox-dark.svg
bookmarks.svg / bookmarks-dark.svg
gainsight.svg / gainsight-dark.svg
logout.svg / logout-dark.svg
profile-action.svg / profile-action-dark.svg
external-link.svg / external-link-dark.svg
portal.svg / portal-dark.svg
admin.svg / admin-dark.svg
comms-hub.svg / comms-hub-dark.svg
events.svg / events-dark.svg
mis.svg / mis-dark.svg
sonar-lite.svg / sonar-lite-dark.svg
theme-toggle.svg / theme-toggle-dark.svg
account-school.svg / account-school-dark.svg
account-trust.svg / account-trust-dark.svg
arrow-open.svg / arrow-open-dark.svg
swap.svg / swap-dark.svg
switch-account.svg / switch-account-dark.svg
zendesk.svg / zendesk-dark.svgConfiguration
Product Switcher
current_productmatches againsttitle, notid.
omnibar.productSwitcher = {
current_product: "MIS",
help_href: "https://help.example.com",
marketplace_href: "https://example.com/marketplace", // shows a "Juniper Marketplace" footer button in the product switcher; omit to hide it
products: [
{
id: "mis",
title: "MIS",
icon: "/icons/mis.svg",
icon_dark: "/icons/mis-dark.svg",
icon_colour: "/icons/logo-mis.svg",
product_gradient: "linear-gradient(135deg, #F45162 0%, #9E112F 100%)",
href: "/mis",
switch_account: false,
active: true,
data_tag: "product-mis", // optional; rendered as data-tag on the product's link (e.g. for Gainsight targeting). Renders `data-tag=""` when unset — see "Data tags" below.
},
{
id: "comms-hub",
title: "Comms Hub",
icon: "/icons/comms-hub.svg",
icon_dark: "/icons/comms-hub-dark.svg",
icon_colour: "/icons/logo-comms-hub.svg",
product_gradient: "linear-gradient(135deg, #00B1FA 0%, #005BE3 100%)",
href: "/comms-hub",
switch_account: true, // shows "Available on other account" label
active: false,
},
],
};Clicking a non-active product shows a redirect modal (countdown spinner, Cancel button, ESC to cancel) and then navigates to its href in the same tab once the countdown elapses. Clicking the active product does nothing (it's already open).
Each product carries its own brand identity via two independent, optional fields:
product_gradient— a CSSbackground-imagevalue (typically alinear-gradient(...)) used as the background of that product's icon tile in the product switcher list and its redirect modal. Products without one fall back to the default purple gradient. The active product'sproduct_gradientis also used as the text-fill color of the product name next to the logo (viabackground-clip: text).icon_colour— a full-color logo image (no light/dark split, no background tile) shown next to the product name in the top-left of the omnibar for the active product. This is a separate field fromicon/icon_dark(which drive the switcher list icons, forced to white on top of the gradient tile) — omiticon_colourto show no icon there.
The top-left of the omnibar is actually two separate links: the Juniper logo goes to the home attribute (tooltip "Go back to Portal"), and the product name/icon goes to the product-home attribute, defaulting to "/" — the home of the app you're currently in (tooltip "{product} Home").
When marketplace_href is set, a "Juniper Marketplace" button appears in the footer of the product switcher dropdown and opens the link in a new tab.
Pending products
A product can be marked temporarily unavailable with pending_until — it still appears in the list, but at reduced opacity with a "Pending" badge, and can't be clicked or navigated to.
{
id: "sonar",
title: "Sonar",
href: "/sonar",
pending_until: Math.floor(Date.now() / 1000) + 5 * 60, // pending for the next 5 minutes
}- A number (Unix seconds since epoch — not JS milliseconds) auto-clears once that time passes — the item flips back to normal on its own, no page reload or config update needed.
truemarks it pending indefinitely, until you update the config yourself (useful when you don't have — or don't want to compute — an exact expiry time).- Omit it, or leave it falsy, for a normal product.
Profile Switcher
If user_img is omitted, the avatar shows the user's initials (derived from user_name) on a joinup-purple background instead of an image.
omnibar.profileSwitcher = {
user_name: "Joe Bloggs",
user_img: "https://...", // omit to show initials instead
account: "Highlands Academy",
account_type: "school", // "school" | "trust" — controls the icon shown next to the account name
product_title: "MIS",
product_icon: "/icons/mis.svg",
product_icon_dark: "/icons/mis-dark.svg",
product_icon_colour: "/icons/logo-mis.svg", // overrides the active product's icon_colour, same as product_icon does for icon/icon_dark
product_gradient: "linear-gradient(135deg, #F45162 0%, #9E112F 100%)", // overrides the active product's product_gradient for the profile menu's icon tile; falls back to the default purple gradient
logout: () => {
console.log("Logout clicked");
},
actions: [
{
id: "profile",
label: "Profile",
icon: "/icons/profile-action.svg",
icon_dark: "/icons/profile-action-dark.svg",
href: "/portal/profile",
data_tag: "profile-action", // optional; rendered as data-tag on the action's element (e.g. for Gainsight targeting). Renders `data-tag=""` when unset — see "Data tags" below.
// Only rendered when the current product isn't "Portal" — see
// "Redirect modal" below.
badge: "Open in portal",
badge_icon: "/icons/external-link.svg",
badge_icon_dark:"/icons/external-link-dark.svg",
modal_title: "Portal",
modal_description: "This will open Portal in a new tab",
modal_delay: 3000,
modal_icon: "/icons/portal.svg",
modal_icon_dark: "/icons/portal-dark.svg",
// Force-skip the redirect modal even outside Portal. You normally
// don't need to set this — the modal already skips itself
// automatically whenever the omnibar's current product is "Portal".
skip_redirect_modal: false,
},
{
id: "custom",
label: "Custom action",
icon: "/icons/custom.svg",
icon_dark: "/icons/custom-dark.svg",
onSelect: ({ action }) => {
console.log(action.id);
},
},
],
other_accounts: [
{
id: "magna",
name: "Magna Academy",
href: "/switch/magna",
type: "school",
data_tag: "other-account-magna", // optional; rendered as data-tag on the account's link. Renders `data-tag=""` when unset — see "Data tags" below.
},
],
};Actions behaviour
| Property | Behaviour |
| --- | --- |
| href | Renders action as <a> |
| no href | Renders action as <button> |
| id: "profile" + href, current product isn't "Portal" | Shows the badge and a redirect modal before opening a new tab |
| id: "profile" + href, current product is "Portal" | No badge; navigates like a normal same-tab link, no modal |
| id: "profile" + href + skip_redirect_modal: true | No badge; navigates like a normal same-tab link, no modal, regardless of current product |
| onSelect | Custom callback fired on click |
Redirect modal
Both switchers show a loading modal whenever navigating to a different app — the product switcher for any non-active product, the profile switcher for its "profile" action.
// Profile action
{
id: "profile",
href: "/portal/profile",
modal_title: "Portal",
modal_description: "This will open Portal in a new tab",
modal_delay: 3000, // ms before redirect
modal_icon: "/icons/portal.svg",
modal_icon_dark: "/icons/portal-dark.svg",
}- Countdown spinner shown during delay
- Cancel button stops the redirect
- ESC key also cancels
- Product switcher: after the countdown, navigates to the product's
hrefin the same tab. Not configurable per-product (fixed 3s delay, no badge, no skip option) — switching apps always needs the loading state, and there's no "already there" case to skip it for. - Profile switcher: after the countdown, opens
hrefin a new tab — the current tab is left as-is so your workflow there isn't lost. The"profile"action's badge and modal are both skipped automatically whenever the omnibar's current product (productSwitcher.current_product/profileSwitcher.product_title) is"Portal", since the link then just leads back into the app that's already open and can navigate in the same tab with no loading state. Setskip_redirect_modal: trueon the"profile"action to force-skip the modal (but not the badge) in other products too.
Nav action icons
Other than the product switcher and profile switcher, every icon in the omnibar nav is optional: it only renders when you provide a value, and is hidden when left null/unset. Each one accepts either:
- a string — renders the icon as an
<a href="...">opening in a new tab, or - a function — renders the icon as a
<button>that calls your callback with{ component }on click
They appear in the nav in this order (right to left, i.e. closest to the profile switcher first):
| Order | Property | Tooltip | Notes |
| --- | --- | --- | --- |
| 1 (rightmost) | help | "Help" | Falls back to productSwitcher.help_href, then a default Juniper help URL, when unset — so Help is effectively always shown unless you override getHelpHref behaviour by setting one of those |
| 2 | gainsight | "Take a tour" | |
| 3 | zendesk | "Contact Support" | |
| 4 | inbox | "Inbox" | Supports a count badge via inboxCount |
| 5 | bookmarks | "Bookmarks" | Supports a count badge via bookmarksCount |
| 6 (leftmost) | search | "Search" | |
// Link
omnibar.inbox = "/inbox";
// Button with a callback
omnibar.gainsight = ({ component }) => {
// trigger your Gainsight integration
};
// Hide any of them
omnibar.zendesk = null;Each icon renders with a fixed data-tag value (e.g. for Gainsight targeting) — see Data tags. These are not consumer-overridable.
inboxCount and bookmarksCount show a small count badge on the corner of the Inbox/Bookmarks icon. Set to 0, null, or leave unset to hide the badge; counts above 99 display as "99+".
omnibar.inbox = "/inbox";
omnibar.inboxCount = 4;
omnibar.bookmarks = "/bookmarks";
omnibar.bookmarksCount = 0; // badge hiddenOther accounts
other_accounts: [
{
id: "account-id",
name: "Magna Academy",
href: "/switch",
type: "school", // "school" | "trust"
},
];Hidden when the array is empty. A long list scrolls internally past a handful of accounts, with a fade cue at the bottom to hint there's more — and if the menu as a whole doesn't fit the window's height, it scrolls too.
Data tags
Every clickable element in the omnibar renders a data-tag attribute (used for analytics/Gainsight targeting).
Most elements — the nav icon buttons, the logo/home link, the burger button, the product/profile switcher toggle buttons, the marketplace footer link, the Logout button, and the redirect modals' Cancel buttons — render a fixed data-tag value baked into this package. These are not consumer-overridable, so tracking stays stable across every embed:
| Element | data-tag |
| --- | --- |
| Logo link (home attribute) | omnibar_home_link+ba42e2f0-8777-4a46-9f70-6501bb4812b7 |
| Product name link next to the logo | omnibar_product_name_link+89660215-77e0-4ba4-9371-8e72e1782fcd |
| Help nav button | omnibar_help_link+3a256eca-ff3c-439b-ade5-647651ac61c0 |
| Take a tour (Gainsight) nav button | omnibar_take_a_tour_icon+0565f03e-2265-439f-aa10-0f0ab5e8dee8 |
| Contact Support (Zendesk) nav button | omnibar_contact_support_icon+594117b8-5858-44b5-9e85-ce41bfab1221 |
| Bookmarks nav button | omnibar_add_bookmark+41981642-cfae-4f43-9ef4-6beb4dd4b78d |
| Inbox nav button | omnibar_inbox_link+9a0d0dd5-a366-431b-972c-ab0aa49e91f4 |
| Search nav button | super_search_trigger_button+6fbe4c5d-1887-4ca7-9c69-c973c2864738 |
| Product switcher search input | product_switcher_search_input+05f0994c-2018-452d-a9fa-163850cb3dc5 |
| Burger/mobile menu button | omnibar_menu_toggle_button+e8367ccb-3c1f-478a-837c-c8c376776323 |
| Product switcher toggle button | omnibar_app_switcher_button+2b10755e-2ac6-43b2-9219-699913a660ff |
| Profile switcher toggle button (avatar) | omnibar_account_button+6e18c400-1465-470a-820b-b454ef7443d4 |
| "Juniper Marketplace" footer link | product_switcher_marketplace_footer_link+e35bbbfc-1d4f-4a90-86f2-0556f8b66e23 |
| Product/profile redirect modal Cancel button | product_switcher_redirect_modal_cancel_button+b54ffe13-e4d1-4f69-9fbe-6bad6253e8a8 / profile_switcher_redirect_modal_cancel_button+fe23ab5e-4691-4af3-9281-fd223fe5a544 |
| Logout button | profile_switcher_logout_button+a56538a2-0bfc-45fd-b83f-9793d126e3d8 |
API-driven items fall back to a shared default data-tag and can be overridden per-item by setting data_tag on the relevant item:
productSwitcher.products[].data_tag(product switcher dropdown items) — defaultproduct_switcher_list_item_link+4ec911d1-15ad-4c86-b18e-2ebc54766290profileSwitcher.actions[].data_tag(profile dropdown actions, e.g. "Profile") — defaultprofile_switcher_action_item+335ff918-b63b-4a19-92fa-0c3d31c1deb5profileSwitcher.other_accounts[].data_tag(other-account links) — defaultprofile_switcher_other_accounts_link+ecbcd8f6-ffa3-4c6a-865d-433f117b1c9f
Attributes
| Attribute | Description |
| -------------- | ----------------------------------------------------------- |
| product | Current product name |
| account | Current account name |
| account-type | "school" (default) or "trust" — icon shown next to the account name |
| home | Juniper logo link href ("Go back to Portal") |
| product-home | Product name/icon link href ("{product} Home") — defaults to "/" |
| environment | "demo", "staging", or "dev" (case-insensitive — "Demo"/"DEMO" also match) — shows a matching badge next to the account name. Any other value (or unset) shows no badge. |
Events
omnibar.addEventListener("jui:menu-toggle", (event) => {
console.log(event.detail); // { source: "burger", component }
});
omnibar.addEventListener("jui:profile-action", (event) => {
console.log(event.detail.action); // the action object
});
omnibar.addEventListener("jui:logout", () => {
console.log("Logout fired");
});Dark mode
Dark mode is activated by setting data-theme="dark" on <body>:
document.body.setAttribute("data-theme", "dark");All icon icon_dark variants switch automatically — no JavaScript required.
Styling
import "jui-omnibar/omnibar.css";Key CSS variables:
:root {
--j-o-color-brand-primary: #271a38;
--j-o-color-brand-secondary: #814af4;
--j-o-font-family: "Inter", sans-serif;
}Loading strategy (avoiding layout jank)
<jui-omnibar> is a plain custom element with position: fixed, so once it
renders it never causes a layout shift in the rest of the page — but if its
script/CSS load after the rest of your app, you'll see it pop in a moment
later, which reads as jarring. The fix in every framework is the same: get
the omnibar's script and stylesheet loading before/independently of your
app framework's bundle, not bundled inside it.
The package ships a jui-omnibar:not(:defined) rule in omnibar.css that
renders an empty bar in the header's exact final position, size, and
background color the instant the stylesheet loads — before the component's
JS has even finished loading. As long as the stylesheet is a normal blocking
<link rel="stylesheet"> in <head> (not injected late, not deferred),
there's a correctly-colored bar on first paint, and the real content simply
appears inside it once the script runs — no pop-in, no shift.
<!-- In <head>, not bundled with your app JS -->
<link rel="stylesheet" href="/path/to/omnibar.css" />
<link rel="modulepreload" href="/path/to/omnibar.js" />modulepreload (or <link rel="preload" as="script"> for non-module
builds) tells the browser to fetch the script at high priority in parallel
with everything else, without executing it yet — it still executes wherever
your actual <script type="module">/import is, but the network round-trip
is no longer on the critical path.
Vanilla HTML
Put the tag and its assets directly in the HTML — nothing to coordinate:
<head>
<link rel="stylesheet" href="/dist/omnibar.css" />
</head>
<body>
<jui-omnibar product="MIS" account="Highlands Academy"></jui-omnibar>
<script type="module" src="/dist/omnibar.js"></script>
<script type="module">
document.querySelector("jui-omnibar").profileSwitcher = {
/* ... */
};
</script>
</body>Vue
The jank usually comes from the omnibar being written inside a .vue
component, so it only appears once Vue's own bundle has downloaded, parsed,
and mounted. Instead, put <jui-omnibar> directly in index.html, outside
#app, so it's real DOM from the first byte — Vue never needs to render it:
<!-- index.html -->
<link rel="stylesheet" href="/dist/omnibar.css" />
<script type="module" src="/dist/omnibar.js"></script>
<body>
<jui-omnibar id="omnibar" product="MIS"></jui-omnibar>
<div id="app"></div>
</body>Then configure it imperatively once your data is ready, rather than through
a template — onMounted (or even earlier, a plugin/main.js top-level call)
is fine since the element itself is already on screen and doesn't need Vue
to exist for that:
// main.js, or a small composable
onMounted(() => {
document.getElementById("omnibar").profileSwitcher = {
user_name: store.user.name,
// ...
};
});If you tell Vue to ignoredElements: [/^jui-/] (Vue 3 app.config.compilerOptions.isCustomElement)
you can still place <jui-omnibar> inside a .vue template and bind props
reactively (:profile-switcher="...") — that's fine for configuring it,
just keep the tag itself, its script, and its stylesheet outside anything
that's lazy-loaded or behind a route-level code-split chunk.
React
Same principle: declare the element in your static HTML shell
(public/index.html for CRA, index.html for Vite, or the root layout for
Next.js), not inside a component that only renders after React hydrates.
<!-- public/index.html -->
<link rel="stylesheet" href="/dist/omnibar.css" />
<script type="module" src="/dist/omnibar.js"></script>
<body>
<jui-omnibar id="omnibar"></jui-omnibar>
<div id="root"></div>
</body>// index.jsx / App.jsx — set props as early as possible
useLayoutEffect(() => {
const omnibar = document.getElementById("omnibar");
omnibar.profileSwitcher = { user_name: user.name /* ... */ };
}, [user]);useLayoutEffect over useEffect here just avoids one extra paint between
mount and prop assignment — not critical, but free. If you'd rather manage
it fully inside React (e.g. an SSR/Next.js app where you don't control a
separate static shell), keep the <link>/<script> tags in <Head> and
render <jui-omnibar> as the very first element in your root layout rather
than nested inside a client-only bundle or a lazy route.
Laravel / Blade
This is the easiest case: Blade renders on the server, so the omnibar can be in the very first HTML response with real user data already filled in — no client-side JS framework needs to exist first.
{{-- layout.blade.php, in <head> --}}
<link rel="stylesheet" href="{{ asset('vendor/jui-omnibar/omnibar.css') }}">
<script type="module" src="{{ asset('vendor/jui-omnibar/omnibar.js') }}"></script>
{{-- immediately after <body> opens --}}
<jui-omnibar
product="{{ $currentProduct }}"
account="{{ $account->name }}"
account-type="{{ $account->type }}"
></jui-omnibar>
<script type="module">
document.querySelector("jui-omnibar").profileSwitcher = @json([
'user_name' => $user->name,
'account' => $account->name,
// ...
]);
</script>Because everything here is server-rendered and inline, there's no
client-side bundle to wait for at all — the :not(:defined) skeleton only
covers the brief gap while omnibar.js itself downloads and parses.
Package structure
dist/
omnibar.js
product-switcher.js
profile-switcher.js
icons.js
omnibar.css
omnibar.d.ts
icons/
*.svg (light mode)
*-dark.svg (dark mode)Development
npm run buildAccessibility
- Keyboard navigation (ESC closes menus and modals)
- Focus management on close
- ARIA roles, labels, and expanded states
- Screen reader announcements for loading states
Security
All consumer-supplied strings (product/action/account titles, user names, badge text, etc.) are HTML-escaped before rendering — safe to pass through values you haven't sanitized yourself.
Every href/icon URL (product.href, action.href, other_accounts[].href, icon/icon_dark/modal_icon/user_img, etc.) is passed through an allowlist: relative URLs and http:/https:/mailto:/tel: are permitted, everything else (javascript:, data:, and other script-executing schemes) is replaced with #.
Migration from v3.0.x
All SVG string props have been replaced with image URL props:
| Before (v3.0.x) | After (v3.1.0) |
| --- | --- |
| icon_svg: "<svg>...</svg>" | icon: "/path/to/icon.svg" + optional icon_dark |
| badge_icon_svg | badge_icon + badge_icon_dark |
| product_icon_svg | product_icon + product_icon_dark |
| modal_icon_svg | modal_icon + modal_icon_dark |
Migration from v3.x (v4.0.0)
omnibar.uservoicehas been removed. There is no direct replacement — it was a single-purpose href-only button. If you still need a link to an external feedback tool, repurpose one of the new nav action icons (e.g.omnibar.search,omnibar.inbox, oromnibar.bookmarks) with your URL as the value.gainsightandzendesknow also accept a string (rendered as a link) in addition to a callback function — existing callback usage is unaffected.- Nav icon order changed. Right to left it's now: Profile, Product
switcher, Help, Take a tour, Contact Support, Inbox, Bookmarks, Search.
helpis a new top-level property (previously only configurable viaproductSwitcher.help_href, which still works as a fallback).
Migration from v4.x (v5.0.0)
- The product name/icon area next to the logo no longer renders from
icon/icon_dark. It now requires a new, separateicon_colourfield (a full-color logo image, no background tile) on the active product — set it inproductSwitcher.products[](or override directly viaprofileSwitcher.product_icon_colour). If you don't addicon_colour, that icon simply won't render — there's no fallback toicon/icon_dark, since those are a different visual treatment (forced white on a gradient tile, meant for the switcher list) than the full-color logo this area now shows. - New optional
product_gradientfield on each product — a CSSbackground-imagevalue used for that product's icon tile in the switcher list/redirect modal and the active product's name text color. Products without one keep rendering with the previous default purple gradient, so this part is backwards-compatible.
License
MIT
