useShellSlot lets a view render controls into a region of the surrounding NbShell: a page title in the topbar, a Save button next to it, a filter row in the fixedbar, a panel in the inspector.
It exists because everybody needed it and everybody built it privately. Eight of our twelve applications put a hand-picked DOM id on a shell region and teleported into it by selector: #cms-topbar, #nks-topbar-right, #verba-topbar, #ob-topbar-left, #nba-topbar-left, #tally-topbar, #page-actions. Eight incompatible contracts, one of which shipped a bug because a view looked for its target before the shell had rendered it. This is the supported version, with those two failure modes handled rather than rediscovered.
Basic usage
<script setup lang="ts">
import { ref } from 'vue'
import { useShellSlot } from '@nubisco/ui'
const topbar = useShellSlot('topbar-right')
const dirty = ref(false)
function save() {
/* ... */
}
</script>
<template>
<component :is="topbar.Outlet">
<NbButton size="sm" :disabled="!dirty" @click="save">Save</NbButton>
</component>
<article>...</article>
</template>The button appears in the shell's topbar. It is still this view's button: Outlet teleports the nodes rather than re-creating them, so dirty, the handler and any component state inside belong to the view and stay reactive from here. When the view unmounts, the contribution goes with it.
Regions
| Region | Where it renders |
|---|---|
outer-menu | Full-width strip above the sidebar, body and inspector |
inner-menu | Strip above the body and inspector, but not the sidebar |
notification | Full-width area above the topbar, for banners |
topbar-left | Left half of the topbar: page title, breadcrumbs |
topbar-right | Right half of the topbar: page actions |
fixedbar | Non-scrolling strip under the topbar: tabs, filters |
bottom | Region pinned under the main area, where NbShellPanel usually goes |
inspector | The optional right-hand column |
The sidebar-* slots are deliberately absent. Navigation is a property of the application, declared once; a per-view contribution mechanism pointed at the rail would let the rail change shape on every route, which is the opposite of what a rail is for.
Ordering is not your problem
A child's onMounted runs before its ancestors'. A view that queries for a shell element on mount is therefore looking too early, and if the target region is conditional (the topbar only exists when it has content) it may not exist at all yet. That is the bug that shipped in one product, patched there with a setTimeout.
There is nothing to time here:
targetis reactive. It isnullon the first tick and becomes the element as soon as the shell mounts the region.Outletrenders when it does.- Registering is what makes a conditional region exist. A shell with no static topbar slots grows a topbar the moment a view claims one, and loses it again when that view unmounts. Nothing to force from outside.
If you do need to know, read ready.
const topbar = useShellSlot('topbar-right')
watch(topbar.ready, (ready) => {
if (ready) measureSomething()
})Two views, one region
During a route transition the arriving view is created before the leaving one is torn down, so both hold the region for a frame or two. Left alone that renders two page titles, or two Save buttons where one of them no longer saves anything.
The default claim, 'last', is exclusive: the most recently registered contribution renders and the rest stand down without unmounting. The arriving view takes the region on the same tick it registers, so the doubled frame never happens. It is reversible: when the newer contribution goes away, the earlier one takes the region back, which is what makes a contribution from a dialog or a nested route behave.
// Exclusive. The page owns the topbar while it is open.
const topbar = useShellSlot('topbar-right')When contributions are genuinely additive, say so per contributor:
// Renders alongside whatever else claimed the region. Lower `order` first.
const sync = useShellSlot('topbar-right', { claim: 'all', order: -10 })Teleports append in mount order, which between concurrently mounted views is whatever order the router created them in. So under claim: 'all' the outlet wraps its content in a flex box carrying a CSS order, and that wrapper is what makes the sort real. The wrapper inherits the region's own gap and its cross-axis alignment (gap: inherit; align-items: inherit, both explicit, since neither inherits on its own), so the contribution lines up with static slot content instead of reading as a nested group, and a region a product has set to align-items: flex-start is not re-centred by a box it did not write. The default claim adds no wrapper.
CSS order applies to flex and grid children only, and three of the eight regions (outer-menu, inner-menu, notification) are block containers on purpose: their contents are the product's markup and their layout is the product's business. NbShell closes that gap by switching exactly those three to a column flex container (.nb-shell__region--ordered) while at least one claim: 'all' contribution is registered, and dropping it again when the last one leaves. Column with the default align-items: stretch reproduces block stacking for full-width children, so the switch is invisible in the ordinary case. It is scoped to this API deliberately: a product that fills those regions by hand and never calls useShellSlot keeps byte-identical layout. The one arrangement that will notice is a region whose children lay themselves out horizontally through display: inline-block rather than through a flex container of the product's own; put a flex container there before using claim: 'all' in it.
Mixing claims in one region resolves in favour of exclusivity: if anything in the region asked to be the only one, it is, and the newest such contribution wins. That is a consumer mistake with a defined outcome, not a supported mode.
Outside a shell
A view that contributes is still a view. Mounted with no NbShell ancestor (a unit test, a print route, a page embedded in something else) the composable is inert: ready stays false, Outlet renders nothing, no warning, no throw. There is nothing to branch on and nothing to stub.
Inside a shell that suppresses the region
Same outcome, different cause, and this one is usually a mistake. <NbShell topbar="never"> renders no topbar, so a contribution to topbar-left or topbar-right has no host element to teleport into: ready stays false and nothing appears. The runtime behaviour has to be this (a view cannot know which shell it will be mounted under), so NbShell warns once in development instead:
[NbShell] A view contributed to the topbar with useShellSlot(), but this shell
renders no topbar (topbar="never"). The contribution is dropped.The fix is on the shell, not on the view: use topbar="auto", or contribute to a region that shell does render. The other regions cannot hit this, because topbar is the only presence the shell can be told to override. In particular the inspector host is always mounted, even while the inspector is closed.
Contributing from a deeper component
Any descendant of the shell can call it, not only the routed view. A table's toolbar can put its own bulk-action bar in the fixedbar while a row is selected, from inside the table component, with no prop threading.
<script setup lang="ts">
import { computed } from 'vue'
import { useShellSlot } from '@nubisco/ui'
const props = defineProps<{ selected: string[] }>()
const bulk = useShellSlot('fixedbar')
const hasSelection = computed(() => props.selected.length > 0)
</script>
<template>
<component :is="bulk.Outlet">
<template v-if="hasSelection">
<span>{{ selected.length }} selected</span>
<NbButton size="sm" variant="secondary">Export</NbButton>
</template>
</component>
</template>A <Teleport> in between is fine: teleporting moves DOM, not the component tree, so inject still finds the shell from inside a teleported dialog. The one thing that does break the chain is a separate application instance mounted at the document root, which has no parent chain into your shell at all. Call the composable inside the shell's own app and pass what you need across.
Destructive actions do not belong here
A contributed control sits in the application's chrome, where it is visible on every route the view owns and where a mis-click costs the most. Anything irreversible you put in a region should confirm first, in a real dialog: window.confirm is a documented no-op inside a Tauri webview and cannot be driven from a gamepad, and two of our products discovered both facts the hard way.