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

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

# angular# showdev# webdev# opensource
Hover isn't a variant: what's new in ng-prism 22.6Alex

Hover isn't a variant: what's new in ng-prism 22.6 ng-prism 22.6 is out. If you haven't...

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

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 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.

Hover as a variant didn't work out

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' } },
],
Enter fullscreen mode Exit fullscreen mode

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.

States

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,
    },
  ],
})
Enter fullscreen mode Exit fullscreen mode

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.

The Overview becomes a matrix

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

Overview of the Button: a matrix with one row per variant and the columns Default, Hover, Pressed and Focus visible. The Disabled row is filled only in Default, the other three cells are dashed and name the reason.

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.

A state only shows up where the browser could produce it

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 pointer-events: none does get hovered. If that cell then looks exactly like Default, good, because that's the test you want: the hover style doesn't leak into the disabled button.
  • 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' } }],
Enter fullscreen mode Exit fullscreen mode

Overview of the Chip: two columns, Default and Remove hovered. The header reads

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.

States in the Playground

Playground with the Outlined button hovered. The tools menu is open and its State group shows the chips Hover (active), Pressed and Focus visible above the single pseudo-class toggles.

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.

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:

Chip

The same Chip after switching on

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

Visual regression

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=.
  • With a state, data-prism-rendered is "<ClassName>:<variant>:<state>". Without one it stays "<ClassName>:<variant>".
  • If the state doesn't fit, .demo-wrap gets data-prism-excluded="<reason>". The runner should report the cell as excluded instead of taking a screenshot.
  • Capture mode (?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:

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Visual Regression panel on the Button: the review list holds

A Claude Code plugin for your library

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. You don't have to clone anything, Claude Code fetches the plugin from GitHub:

claude plugin install ng-prism --marketplace ng-prism/ng-prism
Enter fullscreen mode Exit fullscreen mode

Inside a session it's /plugin marketplace add ng-prism/ng-prism followed by /plugin install ng-prism@ng-prism. With --scope project the plugin gets enabled for the repo in .claude/settings.json. Your teammates still have to install it once themselves, but after that it loads automatically in that repo. Plugins from third-party marketplaces don't update on their own by default, so get new versions with claude plugin update ng-prism@ng-prism.

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.

Faster watch rebuilds

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.

Fixes

  • Aliased inputs and outputs. A variant couldn't set an input like 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.
  • Named content slots. If a component had a named <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.
  • Libraries with only a primary entry point. If you set up such a library with ng add, the styleguide showed no components and no warning either. The primary entry point is now scanned together with the secondary ones.
  • Navigation. Back and forward between two components now restore the variant from the URL instead of jumping to variant 0, and switching the component from the Overview resets variant and state there as well.

Deprecations before 23

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.

What states can't do

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.

Upgrading

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

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.