An inspector is the tall, narrow panel that edits the currently-selected thing: a node, a control, a layer. It is scanned and operated, not read top to bottom, so it lives or dies on information design, not typography. This guide is the correct way to build one from NubiscoUI, and the mistakes to avoid (all of which we have shipped at least once).
The one principle everything else follows: an inspector is dense and quiet, so the values are the only loud thing. A label tells you what a field is; you already know, because you put it there. The value is what you came to read.
Density: use the xs field size
Inspectors pack many controls into a narrow column, so fields must be compact. Every field component (TextInput, NumberInput, Select, DatePicker) takes a size, and inspectors want xs:
<NbTextInput label="Name" size="xs" v-model="name" />
<NbSelect label="Orientation" size="xs" :options="opts" v-model="dir" />| size | height | use |
|---|---|---|
xs | 28px | inspectors, toolbars, dense grids |
sm | 32px | compact forms |
md | 40px | standalone forms (the default) |
lg | 48px | touch-first / hero forms |
The four sizes, one control, one row. The heights in the table above are the ones you are looking at, straight from --nb-field-height-xs … -lg:
Four pixels of difference per step sounds like nothing until it is multiplied by the number of rows an inspector actually carries. Below is the same five-row section twice, in the same 280px column, differing only in the size passed to its controls. Nothing else changes: same container, same NbField rows, same values.
Neither switch is given a size, and that is deliberate. NbSwitch's scale is sm | md | lg (there is no xs), and outside verbose mode none of the three changes anything: the size rules in Switch.vue are nested under .verbose, so a plain switch keeps the base .nb-switch-wrapper box whatever you pass: width: calc(var(--nb-base-unit) * 4) by height: calc(var(--nb-base-unit) * 2) in Switch.vue, and --nb-base-unit is 8px ($base-unit: 8 in src/styles/variables/_layout.scss), so 32x16. It happens to be shorter than an xs row, so it costs nothing here, but do not read that as the size having been honoured. It is the same shape of silent fallback the callout below describes.
NbSwitch also declares name as a required prop (it feeds useStableId, which is what gives the checkbox its id), so every switch on this page passes one. A switch without a name still renders, but it warns, and two switches that fall back to the positional id are one refactor away from colliding.
The size must exist
size is a closed set (xs | sm | md | lg). Every value maps to a real style rule. Historically xs did not exist on field components, so passing size="xs" silently fell back to md and every field rendered ~40% too tall, the whole inspector looked crowded and nobody could tell why. That size is real now, and the type will reject an unknown one, but the lesson stands: if a control looks the wrong size, check that the size you passed is one the component actually styles.
Reach for .nb-inspector
Do not restyle a sidebar by hand, and do not invent a new one per view. Wrap your stacked NbShellPanels in a .nb-inspector container, and put each row in an NbField. The row is a real component (label + control on one grid), so alignment is structural: no :has() coercion, no per-field CSS:
<div class="nb-inspector">
<NbShellPanel title="Properties" fluid v-model:size="a">
<NbField label="Name"><NbTextInput v-model="name" size="xs" /></NbField>
<NbField label="Locked" control="fit"><NbSwitch v-model="locked" /></NbField>
<!-- `size` on a slider reaches its readout, not the track. See below. -->
<NbField label="Drag threshold (px)"><NbSlider v-model="t" :min="0" :max="20" size="xs" /></NbField>
</NbShellPanel>
<NbShellPanel title="MIDI" fluid v-model:size="b"> … </NbShellPanel>
</div>You get:
- One spine. Every
NbFieldshares--nb-field-label-width, so labels and values line up on a single edge you scan straight down, the biggest density and scannability win, and what UE5, Photoshop, Blender, and Figma all do. - Sliders stack with a full-width track and a compact readout, so the track stays usable at any inspector width (a two-column slider collapses when the column is narrow).
- Toggle rows (
control="fit") let the label run wide and push the switch to the right edge, so a long toggle label never truncates. - Compact numerics. Slider readouts and number steppers shrink for the dense rows, via the components' own CSS-var knobs (no specificity fights).
- Cards with breathing room. Panels are inset from the edges and separated by a small gap; each section carries a tinted header cap, and the panel under the pointer (or being edited) tints its top and bottom edges.
- Muted structural headers, so the values stay the loudest thing.
That paragraph is a list of claims, so here is the container making every one of them. This is the real .nb-inspector, three real NbShellPanels and nine real NbField rows, in a 280px column. The two controls change the whole surface at once: size is passed to every field, and --nb-field-label-width is set once on the container, which is the only edit the spine ever needs.
Read down the left edge: nine labels, one spine, no per-field CSS anywhere in this page. The two sliders stacked themselves (label on its own line, full-width track, compact readout) because .nb-inspector keys that off the control, not off markup you write. Both switches sit on the right edge with their labels running wide, from control="fit". Hover a panel and its top and bottom edges tint; collapse Automation with the header's minimise button and its title dims to about 60% until you point at it, which is the "put away reads as put away" claim, live.
Two honest edges of that demo. NbSlider accepts size (it inherits size?: 'xs' | 'sm' | 'md' | 'lg' from IWithFieldAppearance) and emits nb-slider--xs, but it has no .nb-slider--xs rule of its own, so that class styles nothing. The prop is not inert, though: Slider.vue forwards it to the NbNumberInput readout beside the track, which does have a --xs rule (--field-h: var(--nb-field-height-xs)), and the track is 2px at every size, so the row height follows the readout. IWithFieldAppearance's doc comment ("Every value here is backed by a real style rule") is therefore true of the field components and not true of NbSlider's own root. Pass size anyway, it does the work you want here, but know which half of the control it reaches. And the panels only collapse here because each one is bound to a v-model:size ref. NbShellPanel is fully controlled: without that binding its own size buttons emit update:size into nothing and the panel never moves.
Tune the label column with --nb-field-label-width when labels run long, and keep labels short (Send host, not Send to host address).
The legacy in-place conversion, and why it breaks the spine
A bare labeled field dropped straight into .nb-inspector without an NbField wrapper is still converted in place for backward compatibility: _inspector.scss carries :has() rules that coerce .nb-text-input:has(> .nb-label), .nb-select:has(> .nb-label) and a labelled .nb-slider into the same two-column shape. It looks like the pattern works either way. It does not, and this is the state a real migration meets, so here it is.
The conversion uses its own width, --nb-inspector-label-width: 96px, which is not --nb-field-label-width: 7.5rem (120px). Mix the two in one panel and you get two spines about 24px apart. Below, the left column is one panel holding two NbField rows and two bare labelled fields; the right column is the same four values, all four wrapped.
Migrate, do not mix
The in-place rules exist so an old inspector keeps working, not so a new one can skip NbField. A panel that mixes the two shapes is the worst of both: it looks aligned until a label runs long, and then only half the column moves.
The same inspector, hand-rolled
Every mistake this page argues against has shipped. Here they are together, on the left, next to the container on the right. Both columns are made of the same real components with the same values: the left one is what a call site writes when it builds a bespoke aside instead of reaching for the pattern.
The left column contains no hand-written imitation of a control: every field there is the same NbTextInput, NbSelect, NbSwitch and NbSlider as the right, just dropped into a div with its own margins. That is precisely how these get built, and it is why the differences you can see are the pattern's doing and not the components'.
One inspector, everywhere
Every sidebar-of-sections surface, a node inspector, a control inspector, a settings panel, is the same pattern. Use .nb-inspector for all of them rather than hand-rolling a bespoke aside per view; that is how the look stays consistent and the fixes land once.
Hierarchy: one three-step text ramp
Use the text tokens you already have. Do not pin everything to --nb-c-text, that is what makes an inspector read as a wall.
| role | token | size / weight | rendered by |
|---|---|---|---|
| Value (input text, the point) | --nb-c-text | the field's own text size | the control (NbTextInput, NbSelect, …) |
| Label (what the field is) | --nb-c-text-muted | 12px, weight 500, sentence case | .nb-field__label in Field.vue |
| Help / hint (the fine print) | --nb-c-text-subtle | 11px, the quietest | .nb-field__hint in Field.vue |
| Panel header cap (structure) | --nb-c-text-muted | 11px, weight 600, uppercase, 0.06em tracking | .nb-shell-panel__title under .nb-inspector |
Field labels should be quiet: muted and small, sitting in the left column beside their value (sentence case, not the uppercase reserved for the header caps). If a label is as loud as its value, the eye has to work to find the value. Help text is quieter still.
Uppercase and letter-spacing belong to the header cap only. That is the whole reason the cap reads as structure and the label reads as content, and it is worth stating twice because getting it backwards (uppercase labels, sentence- case headers) is the single fastest way to flatten an inspector.
Four roles, one panel, nothing annotated by hand: the ramp below is a real NbShellPanel inside a real .nb-inspector, and the label, hint and header are whatever Field.vue and _inspector.scss render today. Squint at it. The value should be the only thing that survives.
Reading down: OSCILLATOR is the cap (11px, uppercase, tracked, muted, on a tinted bar). Name and Detune are labels (12px, sentence case, muted, no tracking). The text you can edit is the value, at full --nb-c-text. The two grey lines under the controls are hints at --nb-c-text-subtle, 11px, the quietest thing on the surface. Three greys and one white, in that order, every time.
One correction the demo makes for you: the hint is not aligned to the field's left edge. .nb-field__hint sits at grid-column: 2 (Field.vue), so it aligns to the control's left edge, under the value it explains, which is where you want it in a row layout. Only .nb-field--stack moves the hint to column 1, because in a stacked field the control starts there too. Either way the rule is the same: every hint in a panel shares one edge with its own control, and a hint that does not is the bug.
Structure: ShellPanel per section
Group related controls into a ShellPanel. Panels stack in the inspector column; each has a muted header and its own collapse / expand control.
<NbShellPanel title="Properties" fluid v-model:size="propsSize">
<!-- fields -->
</NbShellPanel>
<NbShellPanel title="MIDI" fluid v-model:size="midiSize">
<!-- fields -->
</NbShellPanel>- Use
fluidin a multi-section inspector so each panel takes only the height its content needs, instead of forcing every section to share the column equally. - Collapsed panels dim automatically (the header drops to ~60% and restores on hover), so a put-away section reads as put-away at a glance. You do not style that yourself.
- The panel header is already muted by design. Do not override it back to bright white, that makes headers compete with the field labels below them and flattens the hierarchy.
fluid is the one that is hard to believe from prose, so here are both, side by side, in the place an inspector actually lives: NbShell's inspector column. Each frame is the real shell inside DemoFrame, the shared fixed-height, clipped box this docs site uses whenever a component sized to its parent has to appear in a prose column. The frame is the one thing on this page that is not a library component, which is why it is imported rather than pasted: the sentence and the stylesheet used to be copied per page with the class prefix swapped. Nothing about the shell is changed; it is given a viewport.
Controls that look like controls
Actions in an inspector (Bind, Re-learn, Apply, Send) are buttons, so they must look like buttons at rest, not only on hover. Pick the variant by role:
<!-- The main action: filled -->
<NbButton size="xs" variant="primary">Bind</NbButton>
<!-- Standard actions: subtle fill + border -->
<NbButton size="xs" variant="secondary">Re-learn</NbButton>
<!-- Destructive / dismissive: quiet, outlined -->
<NbButton size="xs" variant="ghost" outlined icon="x">Unbind</NbButton>Do not use bare ghost for actions
variant="ghost" is transparent with a transparent border until hover, it is styled to look like plain text at rest. It is for toolbar-icon affordances inside a dense header, not for a labelled action a user must discover. An inspector full of ghost "buttons" reads as a list of grey words. Use primary / secondary, and reach for ghost outlined only when you genuinely want a quiet, dismissive control that still carries a border.
The argument is about at rest, so this demo is deliberately static: do not hover it, look at it. Four real NbButtons, one row of a real inspector panel:
Spacing: one rhythm
Lay fields out with a single gap on the column, not per-field margins. One consistent vertical rhythm between every field, one consistent gap between a label and its input. Mixed margins are how you end up with one panel's help text indented differently from the rest.
You do not write that gap. .nb-inspector sets it once, on the panel's content box, and every field in every panel inherits it:
// src/styles/patterns/_inspector.scss
.nb-inspector .nb-shell-panel__content {
flex-direction: column;
align-items: stretch;
gap: 6px; // the whole vertical rhythm, in one place
padding: 8px 12px;
}So the correct markup for a rhythm is the correct markup for everything else on this page: .nb-inspector, NbShellPanel, NbField. There is no separate spacing wrapper to write.
<div class="nb-inspector">
<NbShellPanel title="Properties" fluid v-model:size="a">
<NbField label="Name"><NbTextInput v-model="name" size="xs" /></NbField>
<NbField label="Detune"><NbNumberInput v-model="detune" size="xs" /></NbField>
</NbShellPanel>
</div>The failure mode is worth seeing rather than imagining, because it never arrives all at once: somebody adds margin-bottom: 14px to one row, somebody else nudges another to 6px to "tighten it", and six months later no two gaps match. Both columns below hold the same four real NbField rows with the same values. The left one is in a plain div whose children carry their own margins; the right one is in a panel and carries none.
There is no token for the row gap
6px is a literal in _inspector.scss, not a variable, so a surface that genuinely needs a different rhythm has to override .nb-shell-panel__content's gap from outside the pattern. That is a gap in the pattern, not a licence to go back to margins: one override on the container is still one place, and per-row margins are still four.
Keyboard and screen readers
An inspector is operated, not just read, so its keyboard story matters more than a banner's does. A nine-row column is nine or more tab stops before the user reaches anything else on the page.
Tab order is DOM order, and DOM order is your section order. Nothing on this page reorders focus, so the order a user tabs through is exactly the order you wrote the panels and fields in. That is the argument for grouping into NbShellPanels that match how the work is done, not how the data model is shaped: a user tabbing from Channel to CC to Send host is walking your information design.
Collapsing really removes the stops. ShellPanel.vue renders its content under v-if="currentSize !== 'collapsed'", so a minimised section is out of the DOM, not merely hidden. A put-away section costs a keyboard user nothing, which is what makes collapse a real density tool rather than a visual one.
The panel size buttons are reachable and announced. Each is a real <button type="button"> with aria-label (Minimize / Default / Maximize) and aria-pressed reflecting the current size, inside a role="group" labelled "<title> panel size". So they are three tab stops per panel. In a three-panel inspector that is nine stops of chrome sitting between the user and the fields, which is a real cost: prefer fewer, larger sections over many small ones.
Associate the label with its control. NbField renders a real <label :for> and exposes the id it generated to the default slot, so bind it:
<NbField label="Name" v-slot="{ id }">
<NbTextInput :id="id" v-model="name" size="xs" />
</NbField>NbTextInput, NbSelect and NbNumberInput all take id and put it on the control, so this works today. Leave it unbound and the label is visual only: the for points at an id nothing carries, and a screen reader reads an unnamed text field.
NbSwitch cannot take the field's id
NbSwitch derives its id through useStableId and renders the checkbox as `${id}-input`, so <NbField v-slot="{ id }"><NbSwitch :id="id" …> yields for="x" against id="x-input": still no association. Until that is fixed, give a control="fit" toggle row its own accessible name, and expect the row to announce as a checkbox ("checked" / "not checked"), not as a switch ("on" / "off"), because that is the native element underneath.
NbSlider's track is pointer-only
Slider.vue has no tabindex, no keydown handler, no role="slider" and no aria-value* on its track. The only keyboard path to a slider value is the NbNumberInput readout beside it, which is why showInput defaults to true and why turning it off in an inspector makes that row unreachable without a mouse. Do not set :show-input="false" on an inspector slider.
What this page does not demo
Every claim above that could be shown is shown. These could not be, and saying so is cheaper than letting you find out:
- Screen-reader output. Everything in the section above is read from the components' markup, not captured from NVDA or VoiceOver. The two
dangercallouts are the two places that reading predicts a bad announcement; they are predictions, not recordings. - The resize handle.
.nb-inspector's 5px gutter exists to clear the shell's inspector resize handle. The shell frames on this page are 300px boxes, so dragging that handle is not usefully demonstrable at this size. - A real selection. An inspector edits the currently-selected thing, and every demo here is bound to a fixed object. Nothing shows the empty state (no selection) or the multi-select state, because those are application concerns the pattern does not yet rule on. If your inspector has them, they need guidance this page does not give.
- Theme variation. All demos run in whichever theme you are reading in. The token names in the hierarchy table are the contract; the specific greys are the theme's business.
- Long-label truncation.
.nb-field__labelellipsizes, and the fix is--nb-field-label-width(shown live in the container demo) or a shorter label. There is no separate demo of the ellipsis itself.
Checklist
Before you ship an inspector, confirm:
- [ ] Every field is
size="xs"(and the size renders, not falls back). - [ ] Labels are muted and quiet; values are the brightest thing.
- [ ] Help text is subtle and shares its control's left edge (column 2 in a row field, column 1 in a
stackfield), the same for every row. - [ ] Panel headers are the muted default, not overridden bright.
- [ ] Every action is a real button variant, never bare
ghost. - [ ] One
gapsets the spacing, no scattered margins. - [ ] Every row is an
NbField, with no bare labelled fields left relying on the legacy in-place conversion. - [ ] Each control is bound to its field's
id, so the label names it. - [ ] Sliders keep their readout, so the value is reachable from the keyboard.