Skip to content

Icons

How an icon name resolves, why a runtime-built name breaks in production, and what already takes one.

On this page

Icon names are provider-first. lucide:plus resolves from an installed @iconify-json/* package, and local:logo from your own SVG directory. There is no registration step and no per-icon component to import.

BbIcon documents the component. This page is the policy around it, which is mostly about not reaching for it.

Five rules

  1. Prefer provider icons. The house set is lucide:*. Provider sets are plain npm packages you install yourself (npm install -D @iconify-json/lucide), and the plugin resolves them on its own.
  2. Keep local:* for your own artwork: logos, product illustrations, one-off drawings, as SVG files in iconDir.
  3. Never invent a name. Use only names that exist in an installed set or as files in iconDir. A missing icon throws at mount, and in dev that is a Vite error overlay with the exact fix in it.
  4. Write names as static string literals. This one is not obvious and it fails in production only.
  5. Never hand-place BbIcon where a component already accepts an icon through a prop, an item field, or a dedicated slot. The catalogue is further down.

How a name resolves

The bitbossUi Vite plugin, or the Nuxt module under the bitboss config key, serves every icon through the virtual:bitboss-ui/icons module that the runtime plugin hands to BbIcon.

Provider sets are auto-discovered. Every @iconify-json/* package in node_modules is picked up. Install @iconify-json/lucide and every valid lucide:<name> is available; remove it and they all become errors. There is nothing to configure. To see which sets you already have, look for @iconify-json/* in your package.json.

In dev, all icons of the installed sets are registered, and a bad reference fails loudly with an actionable message. You get either "lucide:fo" requires "@iconify-json/lucide" — run: npm install -D … or icon "x" does not exist in the lucide icon set.

In the build, .vue, .ts, .js, .tsx and .jsx sources are scanned, tests and stories excluded, and each referenced icon is emitted as its own lazy chunk. An icon used only in a .stories.ts or .test.ts therefore has to appear as a literal in a scanned source file too.

Local icons: iconDir is scanned recursively, and each .svg becomes local:<basename> with the extension dropped. A bare, unprefixed name normalises to local:. Add or remove a file and dev picks it up live. The plugin also maintains a collection JSON and the VS Code iconify.customCollectionJsonPaths setting, so the Iconify extension previews your local icons too.

Raw SVG is the escape hatch. BbIcon's icon prop also takes a full <svg>…</svg> string, typically an asset imported with ?raw, for a one-off drawing that lives in no collection.

Components that take an icon

Reach for these before BbIcon. Each one places, sizes and aligns the icon to match its own size, which is work you would otherwise redo with CSS.

Buttons. BbButton takes prepend:icon and append:icon beside the label; icon renders an icon-only button. BbDropdownButton takes the same two on the main action, plus right:icon to replace the chevron in the split toggle.

vue
<BbButton prepend:icon="lucide:plus">New invoice</BbButton>
<BbButton variant="outline" append:icon="lucide:download">Export</BbButton>
<BbButton variant="ghost" icon="lucide:settings">Settings</BbButton>

Input affixes. Every batteries-included input takes prepend:icon and append:icon. The append side may already host clear, stepper or toggle controls, so prefer prepend:icon when in doubt.

vue
<BbTextInput v-model="query" label="Search members" prepend:icon="lucide:search" />
<BbNumberInput v-model="amount" label="Amount" prepend:icon="lucide:euro" />
<BbDatePickerInput v-model="dueDate" label="Due date" prepend:icon="lucide:calendar" />

Status surfaces. BbAlert takes icon and iconSize. BbToast takes icon in the useToast() options, and its variants set the tone only, bringing no default icon. BbBadge takes prepend:icon and append:icon, or icon for an icon-only badge, in which case the other two are ignored. BbTag takes the affix pair like the other inputs.

Item config. BbDropdown entries, items and group headers alike, carry 'prepend:icon' and 'append:icon' fields. It is data, not markup:

ts
const memberActions = [
    { key: 'edit', text: 'Edit member', 'prepend:icon': 'lucide:pencil' },
    { key: 'delete', text: 'Delete', variant: 'destructive', 'prepend:icon': 'lucide:trash-2' },
];

append:icon is ignored when the item opens a submenu, which draws a chevron, or sits in a selectable group, which draws a check. BbDropdownButton reuses the same items shape, so its menu entries take icons the same way.

Components that take a slot

These surfaces take arbitrary content rather than a name, so here you do place a BbIcon, or something richer, yourself.

  • BbTabs — the per-tab label-{key} slot, or the generic label fallback.
  • BbBreadcrumbsitem:prepend and item:append around every item, or a named slot per item.key.
  • BbRating — the icon slot replaces each star, with checked, value and size in scope.
  • BbSwitch — the icon slot replaces the track visual. Rarely needed; the default is right almost every time.
  • BbDropdownitem:prepend / item:append, or per-entry ${key}:prepend / ${key}:append, when a leading region needs more than a named icon, such as an avatar.
vue
<BbTabs v-model="tab" :items="tabs">
    <template #label-billing="{ text }">
        <BbIcon icon="lucide:file-text" size="sm" /> {{ text }}
    </template>
</BbTabs>

Size and colour

Icons take the text colour of whatever they sit in. Colour the container, never the icon markup. A color or fill on the icon element is the one thing that breaks when a variant repaints around it.

BbIcon's size takes the xs to 2xl scale, which is 12, 16, 24, 28, 36 and 40 px by default. Retune the whole scale through the plugin's iconDefaultSizes option. size also takes a number in pixels, or a CSS length such as "1.5rem".

Icons you pass through props or item fields are sized and aligned by the host component to match its own size. Do not fight that with CSS. Change the host's size instead.

Naming an icon

Three cases, and only one of them takes a label.

Decorative, next to visible text, such as an affix, a button's prepend or a dropdown item. No label. The text already announces it, and a label would say it twice.

Meaningful and standalone, such as a status icon in a table cell with no words beside it. Give BbIcon a label: it adds role="img" and aria-label.

An icon-only button. Supply the name through the label, which is the default slot or text. It renders visually hidden but is still announced. aria-label is only an override, not the way to name it. The one hard rule is that the button must get its name from one of the two.

vue
<!-- Accessible name from the slot content, hidden visually. No aria-label needed. -->
<BbButton variant="ghost" icon="lucide:settings">Settings</BbButton>

Never wrap a BbIcon in a raw <button>. That is BbButton with icon and a label, and it is shorter.

For a new project, install one provider set, keep every production icon name as a complete literal, and add local SVGs only for artwork the provider should not own. Then run the production build once. That build-time scan is the check that runtime-composed names cannot pass in development.