DEV Community

Cover image for What an Angular-native Storybook alternative actually looks like
Alex
Alex

Posted on

What an Angular-native Storybook alternative actually looks like

ng-prism puts the showcase config on the component, extracts the rest with the TypeScript compiler, and renders into your document instead of an iframe. This is the whole tool, end to end — including the three things that shipped in v22.2: a visual-regression panel, an Overview contact sheet, and a UI rebuilt around a single density scalar.

The default, and what it costs

If you build an Angular component library, you probably run Storybook. Not because you compared options — because it is what exists. It is the thing with the addons, the CI recipes and the Stack Overflow answers, and choosing anything else means explaining yourself.

The price is quiet and you pay it every day. A parallel file tree of *.stories.ts that drifts from the components it describes. A second build toolchain living next to the Angular CLI, with its own config, its own cache and its own upgrade cadence. A preview iframe that isolates your component from the document — which is exactly where CDK overlays, MatDialog and anything else that portals to body want to be. And an abstraction — the component as a flat bag of args — that was designed for a framework generation where inputs were plain properties. I wrote about why that last one matters in the signal era already.

ng-prism is what I built instead — I wrote up the original motivation when it was still an experiment. It is not a port of Storybook's ideas to Angular; it starts from what Angular already gives you — a compiler that knows your types, a builder system that knows your workspace, and a component model built on signals — and asks how little tooling you can get away with on top.

What it is

You annotate the component. That is the whole authoring step.

import { Component, input, output } from '@angular/core';
import { Showcase } from '@ng-prism/core';

@Showcase<ButtonComponent>({
  title: 'Button',
  category: 'Inputs',
  description: 'Flexible button with five visual variants.',
  status: 'stable',
  tags: ['form', 'action'],
  variants: [
    { name: 'Filled', inputs: { variant: 'filled', label: 'Filled' } },
    { name: 'Outlined', inputs: { variant: 'outlined', label: 'Outlined' } },
    { name: 'Elevated', bg: 'dark', inputs: { variant: 'elevated' } },
  ],
})
@Component({
  selector: 'lib-button',
  template: `<button [class]="variant()" [disabled]="disabled()">{{ label() }}</button>`,
})
export class ButtonComponent {
  variant = input<ButtonVariantType>('filled');
  label = input('Button');
  disabled = input(false);
  clicked = output<void>();
}
Enter fullscreen mode Exit fullscreen mode

The generic parameter is optional and worth taking: @Showcase<ButtonComponent> type-checks every variant's inputs against the component's actual input signals, so a renamed input breaks the build instead of silently rendering a default.

At build time a scanner walks your library's entry point with the TypeScript Compiler API and extracts what it needs: which exports carry @Showcase, what their inputs and outputs are called, what types they have, what the defaults are, what the JSDoc says. No runtime reflection, no emitDecoratorMetadata, no decorator evaluation. The decorator itself is a no-op marker — and a shipped transformer (ng-prism-strip) removes it from your compiled output, so publishing a library that uses ng-prism does not make your consumers install ng-prism.

Setup is one command, and it wires ng-prism's builders into your existing angular.json:

ng add @ng-prism/core
ng run my-lib:prism        # dev server on :4400
ng run my-lib:prism-build  # static styleguide for CI / Pages
Enter fullscreen mode Exit fullscreen mode

The ng-prism styleguide: sidebar with categories and status marks, component head, canvas with a rendered card, and the addon dock with plugin tabs

The whole surface: navigation on the left with per-category roll-ups, the component head on top, the stage in the middle with a floating tool rail, and the addon dock below.

That is the Playground. Controls are generated from the input signals the scanner found and pushed through ComponentRef.setInput() — never property assignment, which on a signal input would overwrite the InputSignal with a raw value and break the component on the next render. Events are logged in the Events tab. The code snippet in the tool rail updates as you change inputs.

Three consequences of being Angular-native

No iframe. Components are instantiated through ViewContainerRef.createComponent() into the same document as the styleguide. Overlays land where they belong, focus trapping behaves, and cdkConnectedOverlay positions against the real viewport. This is the single thing I would not give up — and the a11y panel below is the clearest argument for why.

The build is your build. @ng-prism/core:serve and @ng-prism/core:build are Angular builders. They read your workspace, your tsconfig, your styles, your appProviders (provideHttpClient(), provideAnimationsAsync(), whatever your components need). There is no second bundler with a second opinion about your SCSS.

The docs write themselves. The JSDoc plugin turns the comments you already have into an API view — inputs with types and defaults, outputs, examples, @since and @see links — and it stays correct because it is derived from the source, not from a hand-written args table.

API view generated from JSDoc: description, variants list, examples, and a typed inputs table

Accessibility is in the core, not in a plugin

An audit you have to remember to install is an audit that does not run. So the a11y panel is not one of the six official plugins — it ships with @ng-prism/core. The one thing you add is axe-core itself, and even that is a dynamic import, so it is only pulled in when the panel actually audits something.

A11y panel on the Toggle component: score ring at 75 out of 100, one critical button-name violation above the passing rules, tab-order overlay on the canvas

A critical finding on a toggle switch, next to the toggle — not in a CI log three hours later. The green Tab 1 chip on the canvas is the keyboard tab-order overlay.

The audit runs against the rendered variant and re-runs, debounced, whenever an input changes — so it describes the thing you are editing rather than a snapshot from before your last change. Four tabs:

  • Violations — the live axe-core report, sorted by impact (critical → serious → moderate → minor), passing rules listed below the failures, and a score ring that moves as you fix things.
  • Keyboard — the tab-order overlay visible in the screenshot, plus focus-trap detection and missing-tabindex warnings.
  • ARIA Tree — the accessible tree as the browser exposes it, with the computed accessible names.
  • Screen Reader — what a screen reader would announce, as a list or a step-through player, with a perspective toggle that dims the canvas so you stop seeing what your users do not.

This is where the no-iframe decision pays off most. Dialogs, tooltips, menus and anything else riding the CDK overlay are where accessibility violations actually hide, and here they are in the same document the audit runs against instead of portalled out of an isolated preview frame.

When a rule is genuinely wrong for one component, you switch it off where the component is defined rather than in a global config file:

@Showcase({
  title: 'Button',
  meta: {
    a11y: { rules: { region: { enabled: false } } },
  },
})
Enter fullscreen mode Exit fullscreen mode

And when informing you is not enough: the same audit runs headless. Drive the built styleguide variant by variant, run axe-core against .demo-wrap, write an a11y-report.json, and set thresholds in ng-prism.config.ts that fail the build — the header pill then carries the library-wide score into every PR preview. That is its own post.

What Storybook still does better

I would rather say this than have you find it out on day three.

Storybook has an ecosystem I cannot match: interaction tests with play() functions, Chromatic as a hosted visual-review service, MDX documentation, a decade of addons, and CI recipes for every provider. If your organisation ships React and Angular side by side, one tool across both is a real argument. If your workflow depends on component-level interaction testing inside the lab, Storybook has it and ng-prism does not — it gives you an Events panel and an external-tooling contract, which is a different and smaller thing.

ng-prism is smaller, more opinionated, and maintained by one person. It buys you: no story files, no second toolchain, no iframe, signal-native input handling, and plugin panels that read build-time reports. Pick accordingly.

New in v22.2, part I — the chrome went on a diet

The old header stack was three bands of ornament above the thing you actually came to look at: an app header, a component header with a breadcrumb and a title and a status chip and a description and tags, a row of four stat tiles, then a view tab bar, then the variant ribbon. On a laptop, the component had the bottom third of the screen.

v22.2 collapses that. The app header is 40px. The component head is one 36px row: breadcrumb, title, status, and then two buttons. Identity — selector, description, tags, variant count — lives behind an info popover. Measurement — coverage, a11y score, VRT diff — lives behind a gauge popover, with the head itself showing only the worst-deviating metric as a coloured chip.

The component head with the measurement popover open, showing coverage, a11y score and VRT diff

One 36px row. The chip names the metric that deviates most; the popover has the rest.

Both popovers are the native Popover API, which means Escape, light dismiss and top-layer placement are browser behaviour rather than code I have to keep correct.

The band heights are not hard-coded pixels. Each is calc(<px> * var(--density)):

--band-header: calc(40px * var(--density)); /* app header    */
--band-head:   calc(36px * var(--density)); /* component head */
--band-rail:   calc(28px * var(--density)); /* variant ribbon */
--band-tabs:   calc(34px * var(--density)); /* addon tab bar  */
Enter fullscreen mode Exit fullscreen mode

So the entire chrome rescales from one custom property. There is no density setting in a menu, because there does not need to be one:

export default defineConfig({
  theme: { '--density': '0.8' },
});
Enter fullscreen mode Exit fullscreen mode

The canvas tools moved off the band they used to occupy and onto a floating rail over the stage — guides, rulers, background and zoom, and the live Angular template for the current inputs. The sidebar gained per-component status marks with a per-category roll-up (2/3 next to a category means two of its three components carry a mark), and plugins can contribute their own marks through a public extension point.

New in v22.2, part II — Overview, a contact sheet

The Playground is a viewfinder: one variant, big, with controls. That is the right tool for working on a variant and the wrong tool for the question "did the whole component survive the change I just made to the shared token file?" — which needs all of them, at once, side by side.

Overview tab: all nine Button variants as a grid of cells, each on its own declared background, with the full-width variant spanning the row

Every cell renders a real instance of the variant on the background that variant declared — bg: 'dark' gets dark, an undeclared background gets the transparency checkerboard rather than a surface someone invented for it. A variant that stretches has no intrinsic width to compare against its neighbours, so it takes the whole row instead of a column.

There are deliberately no controls. Columns follow the window; everything else a cell shows is what the variant itself declared. The tab appears only when a component has at least two variants — a contact sheet of one is just a worse Playground — and never for a component that renders through a registered page, because those read their state from a single global renderer service and n cells would all show the same thing. That is a correctness argument, not a taste one.

The cells also share none of the Playground's bookkeeping: no output subscriptions, no prism:render:* performance marks, and crucially no data-prism-rendered marker — the selector external screenshot runners key on. One rendered thing means one marker.

New in v22.2, part III — visual regression, next to the component

Which brings us to the runner.

ng-prism does not ship a screenshot runner, and that is a decision rather than a gap. Capturing and comparing belongs to your CI container, because the container owns the three things that decide whether a diff is real: the baselines, the browser version, and the font stack. What ng-prism owns is the contract to drive the app and a rendering mode that makes the output deterministic.

That contract is small. Every variant is addressable by URL (?component=ButtonComponent&variant=1). The element to screenshot is .demo-wrap, and it is public API, not an implementation detail. And ?capture=1 puts the app into capture isolation mode: the canvas gets the whole viewport, zoom locks to 1, transitions and animations freeze document-wide, persisted session state is ignored, and every shell region that is not the canvas is hidden — by a structural rule rather than a selector list, so chrome changes like the tool rail moving do not invalidate a single baseline.

await page.goto(`${base}?component=${cls}&variant=${i}&capture=1`);
await page.waitForFunction(/* … data-prism-rendered … */);
await page.evaluate(() => document.fonts.ready); // fonts, not the marker, are the flake
const shot = await page.locator('.demo-wrap').screenshot();
Enter fullscreen mode Exit fullscreen mode

Your runner compares those against baselines and writes a JSON report. @ng-prism/plugin-visual-regression reads that report at build time and renders it where the reviewer already is:

{
  "total": { "auditedVariants": 80, "unchanged": 74, "changed": 2, "new": 2, "score": 96 },
  "byVariant": [
    {
      "className": "ButtonComponent",
      "variantName": "Outlined",
      "variantIndex": 1,
      "status": "changed",        // unchanged | changed | size-mismatch | new | excluded
      "diffRatio": 0.102624,
      "baselinePath": "vrt/baseline/ButtonComponent/01-outlined.png",
      "currentPath": "vrt/current/ButtonComponent/01-outlined.png",
      "diffPath": "vrt/diff/ButtonComponent/01-outlined.png",
      "bg": "light",
      "baselineBg": "light"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Visual Regression panel: grouped variant list with one changed and one new variant, and a wipe comparison between baseline and current

A few decisions in that panel I would defend:

  • The list is grouped, not flat. A library's variants are overwhelmingly unchanged, and fifteen identical cards bury the one you opened the panel for. Needs review is always open and ordered worst-news-first; Unchanged and Excluded start collapsed. When nothing needs review the rule inverts and the first group opens — two collapsed headers and no rows reads as a panel that failed to load.
  • new is neutral. A variant with no baseline has nothing to regress against. It sits in Needs review because only a human can accept a baseline, but it is never painted as a failure, and the tab badge stays amber rather than red.
  • A diff is never drawn below 1:1. Zoom offers Fit, , , , and a downscaled diff mask is one you cannot trust.
  • Backgrounds are part of the comparison. The report carries both the run's background and the baseline's. When they differ, the panel says so — instead of leaving a reviewer to hunt a regression in an untouched component that simply got photographed on a different surface.

The library-wide score becomes a coloured pill in the header, thresholded in your config, so a deployed PR preview shows its own VRT state before anyone clicks into a component:

visualRegressionPlugin({
  reportPath: 'vrt-report.json',
  assetBaseUrl: 'assets/',
  thresholds: { score: 100 },
});
Enter fullscreen mode Exit fullscreen mode

The rest of the plugin fan

Six official plugins, all registered the same way and all activated in both the build pipeline and the runtime:

Plugin What it adds
JSDoc The API view — inputs, outputs, examples, from your comments
Figma Embedded design frame plus an in-browser design diff
Perf Render profiling per variant
Box Model Hover any element to inspect margin / border / padding / content
Coverage Per-component test coverage read from an Istanbul or v8 summary
VRT The report panel above

Box Model plugin: hovering the rendered card highlights it and renders the nested margin, border, padding and content boxes

Accessibility is deliberately absent from that list, for the reason given above: it is in core, not something you have to remember to install.

Writing your own plugin is the same shape as the official ones: contribute panels, register custom controls for your own types, wrap every rendered component, or enrich the manifest at build time through onComponentScanned / onManifestReady.

Try it

ng add @ng-prism/core
Enter fullscreen mode Exit fullscreen mode

Add @Showcase to one component, run ng run my-lib:prism, and you have a styleguide. Everything in this post is in the live demo — a small component library with all six plugins enabled, real coverage numbers and a real VRT report with a changed variant in it. Angular 20+ workspaces, tested against 20, 21 and 22; components need input() / output() signals.

If you try it on a real library, the feedback I want most is where it breaks — a component that will not render, a control type that has no editor, a builder assumption that does not hold in your workspace. Issues and PRs welcome, and a star helps more than it should.

Top comments (0)