# Hover isn't a variant: what's new in ng-prism 22.6

> Source: <https://dev.to/alexanderspies/hover-isnt-a-variant-whats-new-in-ng-prism-226-3fad>
> Published: 2026-10-19 21:31:25+00:00

ng-prism 22.6 is out. If you haven't come across it yet: ng-prism is a component lab for Angular libraries, a Storybook alternative without story files. You put a `@Showcase` decorator on a component, the TypeScript compiler picks up inputs, outputs and types at build time, and the components render in the same document as the styleguide, without an iframe. [The full tour](https://dev.to/alexanderspies/what-an-angular-native-storybook-alternative-actually-looks-like-3jm3) has the details.

The biggest change in this release is a new concept next to variants, called states. Besides that there's a Claude Code plugin for projects that use ng-prism, watch rebuilds that went from about 450 ms to about 60 ms, a few bug fixes and some deprecations ahead of version 23.

In 22.5 I added `pseudo` to variants. `pseudo: { hover: '.btn' }` puts the button into `:hover` while the variant is shown, using the component's own CSS. That worked, and pretty quickly the button showcase in the demo looked like this:

```
variants: [
  { name: 'Filled', inputs: { variant: 'filled', label: 'Filled' } },
  { name: 'Outlined', inputs: { variant: 'outlined', label: 'Outlined' } },
  // …
  { name: 'Hovered', inputs: { variant: 'filled' }, pseudo: { hover: '.btn' } },
  { name: 'Pressed', inputs: { variant: 'filled' }, pseudo: { hover: '.btn', active: '.btn' } },
  { name: 'Focus visible', inputs: { variant: 'outlined' }, pseudo: { 'focus-visible': '.btn' } },
],
```

After a while this bugged me. These aren't really variants, they're the filled button again, just hovered. In the Playground they show up in the variant ribbon, and if you pick one, hover stays on while you play with the controls. They also only cover the combinations somebody wrote by hand. The filled button had a hover variant, the outlined one didn't. And hiding them from the ribbon wasn't an option either, because then they'd also disappear from the Overview and from the visual regression run, which is where they're actually useful.

Hover, pressed and focus describe what is happening to a component, not what kind of component it is. So in 22.6 they get their own axis.

A component now declares its states once, and every variant gets combined with them:

```
@Showcase<ButtonComponent>({
  title: 'Button',
  states: [
    { name: 'Hover', pseudo: { hover: '.btn' } },
    { name: 'Pressed', pseudo: { hover: '.btn', active: '.btn' } },
    { name: 'Focus visible', pseudo: { 'focus-visible': '.btn' } },
  ],
  variants: [
    { name: 'Filled', inputs: { variant: 'filled', label: 'Filled' }, bg: 'light' },
    { name: 'Outlined', inputs: { variant: 'outlined', label: 'Outlined' } },
    // … Elevated, Text, Icon Only, Disabled, Readonly, On dark
    {
      name: 'Full width',
      inputs: { variant: 'filled', label: 'Full width', fullWidth: true },
      canvasLayout: 'stretch',
      states: false,
    },
  ],
})
```

A state has a name and the same `pseudo` object you already know from variants. That's `true` for the whole component, or a selector (or a list of selectors) for the element the state belongs to, for example the inner `<button>` of a host component. The styling comes from your own CSS. ng-prism rewrites every rule that uses the pseudo-class, so `:hover` becomes `:is(:hover, .prism-pseudo--hover)`, and sets that class on the targets. You don't write any extra styles.

With `states: false` a variant opts out. A full-width button in hover state wouldn't show anything new.

`Variant.pseudo` is still there and isn't deprecated. For a single special case, like a text field with specific inputs plus focus, it's still the simpler choice.

Until now the Overview tab showed all variants side by side. If a component declares states, it shows every variant in every state:

*The Button with nine variants, Default plus three states. Each column header shows the selector of the state and how many variants it fits.*

Each row is a variant, and its background and layout apply to the whole row. That's why `On dark` is dark in every column. If you click a cell, or focus it and press Enter, the Playground opens with exactly that variant and state. This works for every component now, with or without states.

Components without states keep the old Overview. And since the tab now counts cells instead of variants, a component with one variant and one state gets an Overview as well.

In the `Disabled` row three cells are empty. Hover and Pressed say `.btn has pointer-events: none`, Focus visible says `.btn is disabled`. That's on purpose.

Technically ng-prism just sets a class. If your stylesheet draws a focus ring for that class, you'd get a focus ring on a disabled button, a state no user will ever see. For visual regression that's a real problem. The screenshot gets taken, becomes a baseline and is protected from then on, even though it shows something impossible.

So a state only gets applied where the browser could produce it on its own:

`focus` and `focus-visible` skip elements that are `:disabled` (also inside a `<fieldset disabled>`) or below `[inert]`. `aria-disabled="true"` doesn't count, because the browser can still focus that element. If the target is the whole component (`true`), the focusable elements inside it decide.` hover` and `active` need an element that actually receives the pointer, so the target or one of its children must not have `pointer-events: none`. A disabled button without `focus-within` isn't filtered. Without a browser API for focusability there's no reliable way to tell whether some child could take focus.
The selector counts too. The Chip's `Remove hovered` state targets `.sg-chip__remove`, and only removable chips render that button:

```
states: [{ name: 'Remove hovered', pseudo: { hover: '.sg-chip__remove' } }],
```

*There's nothing to configure for this. Chips without a remove button get an empty cell with the reason.*

There are six possible reasons: `<target> matches nothing`, `<target> is disabled`, `<target> is inert`, `<target> has pointer-events: none`, `forces no state` and `states: false`. If a state doesn't fit a single variant, it's almost always a typo in the selector. In that case the column header gets marked and you get one warning in the console.

One case took me longer than the rest. With hover-reveal CSS like `.row:not(:hover) .actions { pointer-events: none }`, whether `.actions` can be hovered depends on another forced class. An early version gave different results depending on how often the component had rendered. Now every pass measures the same way. It sets all forced classes, checks which targets are reachable and removes the class from the ones that aren't. So the first render, the one screenshot runners wait for, gives the same result as every later one.

The variant ribbon only shows variants again. The State group in the tools menu now has a button for each declared state, above the `:hover` to `:focus-within` toggles that were already there. Pick `Hover` and it gets applied to whatever variant you're looking at.

The state stays on when you switch to another variant, so you can click through a whole column of the matrix. When you switch to another component, it's reset. The controls keep working, so you can change the label of a hovered button and it stays hovered. The state is also part of the URL. `?component=ButtonComponent&variant=1&state=0` opens the outlined button in hover state, [here it is in the demo](https://ng-prism.github.io/ng-prism/demo/?component=ButtonComponent&variant=1&state=0).

If a state doesn't fit the current variant, its button is dashed and the tooltip tells you why. You can still select it. It stays selected and kicks in as soon as the target exists:

*On the `Default` chip there's nothing to hover. Turn on `removable` in the controls and the state applies.*

ng-prism doesn't come with its own screenshot runner, because baselines, browser version and fonts belong to your CI. What it provides is a contract for driving the app, and 22.6 extends it. All of it is additive. Without states nothing changes, so existing runners and baselines keep working.

`window.__PRISM_MANIFEST__` lists the states of each component as `states: [{ name, index, pseudo }]`. A variant that opts out has `states: false`.`?state=<index>` selects a state, next to `?variant=`.` data-prism-rendered` is `"<ClassName>:<variant>:<state>"`. Without one it stays `"<ClassName>:<variant>"`.`.demo-wrap` gets `data-prism-excluded="<reason>"`. The runner should report the cell as excluded instead of taking a screenshot.`?capture=1`) applies the state and leaves some space around the component, so focus rings outside its box don't get cut off.
Here's the loop from the docs, a bit shortened:

``` js
for (const comp of manifest.components) {
  for (const variant of comp.variants) {
    // The variant on its own (null), then in each state, unless it opted out.
    const cells = [null, ...(variant.states === false ? [] : (comp.states ?? []))];

    for (const state of cells) {
      const url = new URL(baseUrl);
      url.searchParams.set('component', comp.className);
      if (variant.index > 0) url.searchParams.set('variant', String(variant.index));
      if (state) url.searchParams.set('state', String(state.index));
      url.searchParams.set('capture', '1');
      await page.goto(url.toString());

      const key = `${comp.className}:${variant.index}${state ? `:${state.index}` : ''}`;
      await page.waitForSelector(`.demo-wrap[data-prism-rendered="${key}"]`);

      const excluded = await page.locator('.demo-wrap').getAttribute('data-prism-excluded');
      if (excluded !== null) {
        reportExcluded(comp, variant, state, excluded); // status: 'excluded', with the reason
        continue;
      }

      await page.evaluate(() => document.fonts.ready);
      const png = await page.locator('.demo-wrap').screenshot();
      // compare against e.g. 01-outlined--hover.png
    }
  }
}
```

The index of a state is its position in the source. If the scanner can't read an entry, the slot stays there as an empty state. A typo in one state can't move the next one onto the wrong baseline that way.

The VRT plugin reads two new optional fields from the report, `stateIndex` and `stateName`, and lists state captures next to their variant:

Most coding agents have never seen ng-prism. Ask one to add a showcase and it guesses, usually with Storybook patterns. That's why the ng-prism repo is now also a Claude Code plugin marketplace:

```
/plugin marketplace add ng-prism/ng-prism
/plugin install ng-prism@ng-prism
```

In a shell it's `claude plugin marketplace add ng-prism/ng-prism` and `claude plugin install ng-prism@ng-prism`. Add `--scope project` to both commands and the plugin ends up in `.claude/settings.json`, so your team gets it too.

| Skill | For | 
|---|---|
| `ng-prism:showcase` | Writing or changing `@Showcase` configs, fixing`⚠ ng-prism:` build warnings | 
| `ng-prism:setup` | `ng add` ,`ng-prism.config.ts` , the official plugins, theming, publishing, upgrades | 
| `ng-prism:write-plugin` | Your own plugin: build-time hooks, panels, controls, header widgets, sidebar decorations | 

The showcase skill doesn't stop after writing the config. It starts the styleguide, checks the build output for warnings and takes a screenshot of every variant it changed, using the Playwright from your project. So the agent actually looks at the result before saying it's done. The skills check which version of `@ng-prism/core` you have installed and go by its typings, and the plugin version follows the ng-prism releases.

`ng run my-lib:prism` scans the library again on every save. The scanner already passed the previous `ts.Program` to `ts.createProgram`, but TypeScript only reuses a `SourceFile` if the compiler host returns the exact same object. The default host parses the file again on every call, so each rebuild parsed the whole program, Angular's typings included. Now there's a cache that keeps the parsed `SourceFile` as long as the file's mtime and size don't change.

I measured it on the demo library with 15 components, 100 rebuilds with one component change each:

|  | 22.5 | 22.6 | 
|---|---|---|
| Warm rebuild, mean | 452 ms | 64 ms | 
| Warm rebuild, p95 | 524 ms | 102 ms | 
| Memory (RSS) | ~372 MB | ~271 MB | 

There's a smaller one too. Reading the `paths` from your tsconfig no longer lists every file below it. In the demo workspace that went from 9.25 ms to 0.02 ms per rebuild, and in bigger workspaces the difference is bigger.

`size = input('md', { alias: 'buttonSize' })` because Angular threw NG0303, and a directive host with an alias broke the styleguide build with NG8002. Variants and controls still use the member name, which is what `InputsOf<T>` expects, and ng-prism binds the public name. Aliases from a constant work too.`<ng-content select>` before the default slot, variant `content` ended up in the wrong slots. Each key now goes into the slot its selector matches.`ng add`, the styleguide showed no components and no warning either. The primary entry point is now scanned together with the secondary ones.
ng-prism versions follow the Angular majors, so a breaking change within 22 comes as a deprecation first. A few config and plugin options have existed since the first release without ever being used. 22.6 marks them as `@deprecated`, the build prints a note for each one you set, and they'll be removed in 23.0.0:

`NgPrismConfig`: `ui`, `appComponent`, `themeStylesheet`, `headless`
`NgPrismPlugin.wrapComponent` and a panel's `position`
`perfPlugin()`: `thresholds`, `bundle.excludeImports`, `render`, `memory` and the `PERF_PLUGIN_CONFIG` token
None of them ever did anything, so removing them from your config doesn't change any behaviour.

States are CSS pseudo-classes only: `hover`, `active`, `focus`, `focus-visible` and `focus-within`. Anything controlled by an input, like selected, expanded, loading or invalid, is still a variant.

The limits of `pseudo` from 22.5 still apply. A state after a pseudo-element (`::part(label):hover`) can't be forced, Shadow DOM isn't supported, and stylesheets from another origin can only be rewritten if they're served with CORS.

Also keep in mind that the matrix grows fast. 15 variants with 5 states means 90 screenshots per component. `states: false` and selectors that only match where they make sense help with that, but think about which states you really need.

```
ng update @ng-prism/core
```

Update the plugins you use at the same time, e.g. `ng update @ng-prism/core @ng-prism/plugin-visual-regression`. There's nothing to migrate, `Variant.pseudo` works exactly like in 22.5. If you want to switch to states, remove the hover, pressed and focus variants and declare the states once instead.

You can try all of this in the live demo, a small component library with all plugins enabled:

I'm curious which states your components need that a CSS pseudo-class can't cover. Drag-over, open or loading maybe? I'd rather plan the next step around real components than around my own guesses, so let me know in the comments or open an issue.
