Configuration

  • Get started
  • 6 min
  • 5 snippets

Defaults for a subtree through GrConfigProvider, the order they resolve in, auto-import and the runtime adapter.

Reviewed 2026-10-20

On this page

Configuration here has two layers and they do not overlap. At build time you configure what reaches the CSS — that is granum.config.mjs and the installation page. At runtime you configure how components behave by default — and that is GrConfigProvider, which this whole page is about.

Defaults for a subtree

vue
<GrConfigProvider
  size="sm"
  :component-defaults="{ GrButton: { variant: 'outline' }, GrInput: { clearable: true } }"
>
  <App />
</GrConfigProvider>

The provider renders transparently (display: contents) and works through provide/inject, so it changes no layout and can sit anywhere — including nested several times over. A child provider merges over its parent at the prop level, not at the component block: override one variant and you do not lose the rest of the GrButton defaults.

The resolution order

  1. A local prop on the component itself.
  2. componentDefaults[Component] of the nearest provider.
  3. The provider’s global size.
  4. The component’s own default.

The componentDefaults layer is itself assembled from three, and each upper one changes a single prop without wiping the rest: the default props of the build’s theme, on top of them the application’s createGranularity({ componentDefaults }), on top of that the providers from the outermost to the nearest. The theme’s props arrive with the virtual:granum/themes manifest: components see them once the manifest is handed to initThemeEarly or granularityThemePlugin.

Step 3 applies only to the size prop of controls: overlays have a scale of their own, and the global size leaves them alone. A button inside a GrButtonGroup takes the group’s value before the config.

Which gives a rule worth knowing as a consumer too: a prop configurable through the provider is declared with a default of undefined. Otherwise Vue would substitute its value before the component ever looked into the config, and the provider would quietly stop working.

The effective config can be read from the application with useGrConfig() — that is public API, not an internal.

Two size scales

The provider’s size is about controls: xs | sm | md | lg. Overlays have a scale of their own — sm | md | lg | xl | full — and the global size does not touch it, because xs means nothing for a modal window. Window size is set pointwise:

vue
<GrConfigProvider :component-defaults="{ GrModal: { size: 'lg' } }">

That is how GrModal, GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette and GrDrawer are configured.

A mode for a subtree

mode puts the mode’s pair on the wrapper: mode="dark" gives data-theme="slate-dark". Modes are declared with an attribute selector, so a dark island inside a light page works with no extra styles. theme together with mode is an island of another build theme from the config’s themes: the provider loads the additional theme’s sheet itself and takes that theme’s default props.

Overlay panels teleport into body and live outside the wrapper in the DOM — but in the component tree they stay inside, so inject reaches them and they set the pair on themselves. The modal, the drawer, the dropdown, the popover, the tooltip, the selects, the toaster and the image viewer are all covered.

The document’s mode is a different job: useTheme and initThemeEarly do it, see theming. The package has no two mechanisms for one thing, and the provider’s props are about the island.

The layering scale and the mount point

zIndexBase recomputes all seven layers of the scale from a base (bottom-nav −150, navbar −100, dropdown +0, tooltip +50, modal +100, loading +150, toast +200 — the same steps as in the theme) and sets them on <html>, restoring the previous values on unmount. The whole scale moves: a layer left in place would end up under somebody else’s modal or above its toasts. On :root rather than on the wrapper — for the same reason panels set the mode’s pair on themselves: a panel moves into body and never sees the subtree’s variables. There is one scale per document, and a second provider with a different base warns about the conflict in a dev build.

The same result is seven lines of CSS. The prop is for where the base comes from at runtime — a micro-frontend inside somebody else’s application, for instance.

portalTarget names the container the subtree’s overlays move into. By default that is the shared #gr-portal in body, which the package creates itself the first time an overlay renders in the browser.

The provider only names the target — it does not create the DOM, and the container has to exist by the time an overlay opens. There is one requirement for it, and it is strict: no transform, filter, contain, perspective or will-change. Each of them creates a containing block for position: fixed, and the floating panels start measuring from the container instead of the viewport.

Auto-import

The recommended way for a production application is the resolver for unplugin-vue-components. It inserts static imports on the package’s subpaths into the SFC, so tree-shaking stays tight: the bundle gets exactly what appeared in the templates.

bash
yarn add -D @feugene/unplugin-granularity unplugin-vue-components
vite.config.ts
import Components from 'unplugin-vue-components/vite'
import { GranularityResolver } from '@feugene/unplugin-granularity'

export default defineConfig({
  plugins: [vue(), Components({ resolvers: [GranularityResolver()] })],
})

No CSS needs wiring up for this: most components have no CSS of their own, and for the rest (22 of 84) granum collects it from the package manifest into the granum.components layer of virtual:granum.css. Component chunks import no CSS, so leave importStyle off on the core resolver: the package publishes no such subpath.

One resolver serves the core and every companion package: it finds the packages in the application’s package.json and the components in their exports, so the order of resolvers does not matter. A part of a compound component (GrListItem) comes from its parent’s subpath, and a name no package exports is not resolved at all — instead of an import of a path that does not exist, Vue warns about an unknown component.

The runtime adapter

The resolver scans templates. Directives applied in render, JSX or TSX are invisible to it — createGranularity is for those:

src/main.ts
import { createApp } from 'vue'
import { createGranularity } from '@feugene/granularity/vue'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { vHotkey } from '@feugene/granularity/directives'
import App from './App.vue'

createApp(App)
  .use(createGranularity({
    components: [GrButton],
    directives: [{ name: 'hotkey', directive: vHotkey }],
  }))
  .mount('#app')

The adapter imports no component itself — it takes them as an argument. The bundle gets exactly what you passed and nothing beyond. The flip side of the same property: passing “everything” here means losing granularity, and tree-shaking will not save you, because the import was yours.

Beyond components and directives the adapter takes provides and globalProperties — one convenient entry point for a bootstrap file instead of a scattering of app.use(...).