Storybook

  • Integrations
  • 7 min
  • 7 snippets

The library has no Storybook of its own and will not have one. How to plug the components into yours — config, theme, overlays and the main trap.

Reviewed 2026-10-16

On this page

Granularity has no Storybook of its own, and none is planned. Behind the question “do you have a Storybook?” there are almost always two others: does the company get a single showcase of components and does the library fit into its visual testing processes. The answer to both is yes, and this page is about how.

Why not one of our own

The portal already does what a Storybook is set up for: live demos on every component page, an API table straight from the code, a playground with editable props and accessibility checks. A second such tool alongside would mean a second source of truth and the duplication of hundreds of demos — with a single maintainer that is a straight road to divergence, and pairs like that diverge silently.

The same goes for Chromatic and Histoire: nothing of our own is built.

Your own Storybook still makes sense — but for a different reason. Your components live in it, assembled out of ours, and it is they that need a showcase.

What to plug in

Granularity differs from any other Vue library in one respect only: its CSS is not imported as a file but assembled by granum from the package manifest (installation). So the whole setup is about making the granum plugin run inside Storybook.

The build

Storybook on Vite (@storybook/vue3-vite) reads your vite.config.ts. If the granum plugin is already there, there is nothing to add to .storybook/main.ts:

vite.config.ts
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import { granum } from '@feugene/granum/vite'
import granumConfig from './granum.config.mjs'

export default defineConfig({ plugins: [Vue(), granum(granumConfig)] })

If Storybook has a config of its own, the plugin is added through viteFinal:

.storybook/main.ts
import { granum } from '@feugene/granum/vite'
import granumConfig from '../granum.config.mjs'

export default {
  framework: '@storybook/vue3-vite',
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  viteFinal: async config => ({
    ...config,
    plugins: [...(config.plugins ?? []), granum(granumConfig)],
  }),
}

The styles

The same two imports as in the entry point of the application — in preview.ts, and the order between them is just as load-bearing:

.storybook/preview.ts
import '../src/reset.css'
import 'virtual:granum.css'

The reset has to sit in a layer declared before the granum.* layers — otherwise it wins against the whole library, and the stories render with no borders and colourless buttons. The details are in troubleshooting.

The trap: the stories are not in appSources

The stories rendered, the components in them look right — and a utility class written inside a story (a grid gap-4 wrapper, the width of the stage) does nothing.

A component’s classes arrive from the package manifest and do not depend on this. The classes of your markup, though, granum looks for on disk, in the appSources.dirs directories. If the stories live outside src, they have to be named:

granum.config.mjs
import { defineGranumConfig } from '@feugene/granum/vite'
import { windEngine } from '@feugene/granum-engine-wind'

export default defineGranumConfig({
  engine: windEngine(),
  providers: ['@feugene/granularity'],
  themes: { names: ['light', 'dark'] },
  appSources: { dirs: ['src', '.storybook'] },
})

The older wording of this trap — “the extractor does not look into the package’s dist” — does not apply to granum: a component’s classes were counted when the package was built and live in its manifest, and granum rereads the package’s files itself only when your engine’s vocabulary has drifted from the package’s. None of this depends on appSources. Exactly half the problem is left, and that half is about your code, not somebody else’s.

Listing components in a Storybook config is usually unnecessary: a showcase by its nature shows everything, and the weight of its CSS bothers nobody. Narrowing the list is a production-application technique, and it stays there.

Wrapping the stories

Subtree defaults and the target for overlays are set by GrConfigProvider. In Storybook it lives in a decorator:

.storybook/preview.ts
import { GrConfigProvider } from '@feugene/granularity/components/GrConfigProvider'
import { h } from 'vue'

export const decorators = [
  (story: () => unknown) => ({
    setup: () => () => h(GrConfigProvider, { size: 'md' }, () => h(story() as never)),
  }),
]

The provider renders transparently (display: contents) and does not change the layout of a story, so it can be put on every story at once.

Overlays

Modals, dropdowns and tooltips teleport into a shared portal — #gr-portal in body, which the package creates itself on the client when the first teleporting component renders. In Storybook that works with no setup: the preview has a body of its own, and the portal appears in it.

Setup is needed only if you name a container of your own with portalTarget. Then the requirement on it is the same as in an application, and it is a hard one: no transform, filter, contain, perspective or will-change — they create a containing block for position: fixed, and the panels start computing their position from the container rather than from the viewport. Storybook decorators create wrappers like that readily.

Switching the theme

The theme is an attribute on the document root, and it is switched the same way as in an application:

.storybook/preview.ts
export const globalTypes = {
  theme: {
    toolbar: {
      items: [
        { value: 'light', title: 'Light' },
        { value: 'dark', title: 'Dark' },
      ],
    },
  },
}

export const decorators = [
  (story: () => unknown, context: { globals: { theme?: string } }) => {
    document.documentElement.dataset.theme = context.globals.theme ?? 'light'
    return story()
  },
]

The decorators from both snippets live in a single decorators array: a second export const decorators in the same file will not compile.

Both themes have to reach the CSS — themes.names in the config above is responsible for that: without it granum takes the themes the core declares as defaults, and that is light alone. Name only light there, or leave the field out, and you get a switcher that changes an attribute and changes nothing else.

Imports in stories

The auto-import resolver (@feugene/unplugin-granularity) works in SFC templates. A story in TypeScript is not a template, and a component in it is imported by its subpath:

src/stories/Button.stories.ts
import { GrButton } from '@feugene/granularity/components/GrButton'

export default { component: GrButton }

export const Primary = { args: { variant: 'primary', tone: 'primary' } }

A subpath rather than a root import: that is the whole point of granularity — one component reaches the story bundle, not the package. granum also rejects a component from the root as a build error, ComponentRootImportError.

What to take from the portal instead of rewriting

  • Story arguments — the API table on a component page is generated from the code, types and default values included. There is no point copying it into argTypes by hand: Storybook infers most of it from the SFC types itself.
  • Accessibility checks — the @feugene/granularity/testing subpath gives an environment for mounting and the cleanup between tests, while axe and Playwright are plugged in by you. The details are on the testing page. @feugene/granularity-test-kit is mostly about contract tests of the family’s packages, but its ./a11y (axe in jsdom) and ./e2e (keyboard traversal and axe in Playwright) work for an application too.
  • Keyboard contracts — they are described in the library repository per component, and a story does not replace them: they are about behaviour rather than about looks.