Storybook

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.

Machine-translated, not yet reviewed. Read the original

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, accessibility checks and visual regression. 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: compatibility is documented, 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 UnoCSS from the granular provider (installation). So the whole setup is about making UnoCSS run inside Storybook.

The build

Storybook on Vite (@storybook/vue3-vite) reads your vite.config.ts. If the UnoCSS 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 UnoCSS from 'unocss/vite'

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

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

.storybook/main.ts
import UnoCSS from 'unocss/vite'

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

The styles

The same three imports as in the entry point of the application — in preview.ts:

.storybook/preview.ts
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno:granular.css'
import 'virtual:uno.css'

The second import exists only with layer: 'granular' in the config. Without the layer everything arrives in a single virtual:uno.css, and the extra import breaks the build. Forgetting virtual:uno.css, on the other hand, gets you components with tokens but without layout: the colours are there, the shape is not.

The main trap: the extractor does not see dist

This is the first thing that breaks, and it breaks the same way for everyone: the stories rendered, and the components arrived without styles.

The cause is not Storybook. The utility classes the component templates are drawn with live in the built dist of the package, and the UnoCSS extractor does not look there by default — it scans the sources of your project. It has to be told:

uno.config.ts
import { defineConfig, presetMini } from 'unocss'
import { granularContent, presetGranularNode } from '@feugene/unocss-preset-granular/node'
import granularityProvider from '@feugene/granularity/granular-provider/node'

// One object for both calls: let them diverge and the extractor scans
// something other than what the preset generates.
const granular = {
  providers: [granularityProvider],
  themes: { names: ['light', 'dark'] },
  layer: 'granular' as const,
}

export default defineConfig({
  content: granularContent(granular),
  presets: [presetMini(), presetGranularNode(granular)],
})

content is read only from the top level of the config, not from the preset. This is the same requirement as in an application — Storybook simply makes it more visible, because a separate config is more often used there.

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 first opening. 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()
  },
]

Both themes have to reach the CSS — themes.names in the config above is responsible for that. Name only light there 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.

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 not needed for this: it is about contract tests of the family’s packages rather than of an application.
  • 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.

Last reviewed: 2026-09-02