Loki: Storybook Visual Regression Testing

Loki: Storybook Visual Regression Testing

Loki is a visual regression testing tool purpose-built for Storybook. It runs locally and in CI, captures screenshots of every story, compares them against stored references, and shows diffs when components change. Unlike cloud-hosted alternatives, Loki stores reference images in your repository and runs entirely offline.

Why Loki

Loki fills a specific niche: teams that want visual regression testing without external service dependencies or per-screenshot billing. References live in .loki/reference/ — committed to your repository, diffed in code review, and available without network access.

Tradeoffs vs hosted alternatives:

  • Pro: fully offline, no API keys, no billing, references in version control
  • Pro: fast — runs in the same process as your Storybook
  • Con: no multi-browser support (Chromium only via Puppeteer/Chrome)
  • Con: no hosted review UI — diffs are local image files

Installation

npm install --save-dev loki
npx loki init

init adds Loki configuration to package.json and creates .loki/ directory structure.

Configuration

Loki is configured in package.json:

{
  "loki": {
    "configurations": {
      "chrome.laptop": {
        "target": "chrome.app",
        "width": 1366,
        "height": 768
      },
      "chrome.iphone7": {
        "target": "chrome.app",
        "width": 375,
        "height": 667
      }
    }
  }
}

Targets:

  • chrome.app — requires Chrome installed locally (fastest)
  • chrome.docker — runs Chrome in Docker (consistent across machines)
  • ios.simulator — requires macOS + Xcode
  • android.emulator — requires Android SDK

For CI and team consistency, use chrome.docker.

Docker Target Configuration

{
  "loki": {
    "configurations": {
      "chrome.laptop": {
        "target":  "chrome.docker",
        "width":   1366,
        "height":  768
      },
      "chrome.mobile": {
        "target":  "chrome.docker",
        "width":   375,
        "height":  667
      }
    }
  }
}

With chrome.docker, Loki pulls and starts a Chrome container automatically. Requires Docker to be running.

Commands

# Start Storybook first
npm run storybook &

<span class="hljs-comment"># Capture reference screenshots (first run or after intentional changes)
npx loki update

<span class="hljs-comment"># Test against references (fails on any difference)
npx loki <span class="hljs-built_in">test

<span class="hljs-comment"># Approve current screenshots as new references
npx loki approve

The workflow:

  1. update — creates .loki/reference/*.png files
  2. Commit references to git
  3. test in CI — fails if any story differs from reference
  4. After intentional changes: approve, commit new references

Storybook Integration

Loki discovers stories from your Storybook configuration. It supports Storybook 6 and 7.

For Storybook 7, ensure storiesOf or CSF format stories are registered:

// src/components/Button/Button.stories.jsx
export default {
  title: 'Components/Button',
  component: Button,
};

export const Primary = {
  args: { variant: 'primary', children: 'Click me' },
};

export const Secondary = {
  args: { variant: 'secondary', children: 'Click me' },
};

export const Disabled = {
  args: { variant: 'primary', disabled: true, children: 'Disabled' },
};

Loki captures each named export as a separate screenshot.

Filtering Stories

Run Loki on a subset of stories with --storiesFilter:

# Test only Button stories
npx loki <span class="hljs-built_in">test --storiesFilter <span class="hljs-string">"Components/Button"

<span class="hljs-comment"># Test only components in the 'Forms' section
npx loki <span class="hljs-built_in">test --storiesFilter <span class="hljs-string">"Forms/"

<span class="hljs-comment"># Test by story name
npx loki <span class="hljs-built_in">test --storiesFilter <span class="hljs-string">"Primary"

This is useful during development — test only the stories you're currently changing.

Handling Dynamic Content

Stories with dynamic content (random data, current timestamps, animated elements) produce unstable screenshots. Fix at the story level:

Disable animations in Storybook:

// .storybook/preview.js
export const decorators = [
  (Story) => {
    // Disable CSS animations for all stories
    const style = document.createElement('style');
    style.textContent = `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
      }
    `;
    document.head.appendChild(style);
    return <Story />;
  },
];

Use fixed data in stories:

// Don't use Math.random() or Date.now() in stories
export const UserCard = {
  args: {
    user: {
      name: 'Alice Johnson',
      joinDate: '2024-01-15',  // fixed, not new Date()
      avatar: '/test-avatar.png',
    },
  },
};

Mock timers in stories with Storybook decorators:

// .storybook/preview.js
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';

export const decorators = [
  (Story, context) => {
    if (context.parameters.mockDate) {
      // Use @sinonjs/fake-timers or similar
    }
    return <Story />;
  },
];

Configuration: Thresholds and Diffing

Add threshold configuration for antialiasing tolerance:

{
  "loki": {
    "diffingEngine": "looks-same",
    "configurations": {
      "chrome.laptop": {
        "target":   "chrome.docker",
        "width":    1366,
        "height":   768,
        "tolerance": 2.3,
        "antialiasingTolerance": 3.5
      }
    }
  }
}

tolerance uses looks-same antialiasing-aware comparison. Increase it to reduce false positives from font rendering differences.

Viewing Diffs

When loki test fails, diffs are written to .loki/difference/. Each diff image shows:

  • Left: reference (what it looked like before)
  • Right: current (what it looks like now)
  • Highlighted: changed pixels
# Open diffs on macOS
open .loki/difference/*.png

<span class="hljs-comment"># Or list what changed
<span class="hljs-built_in">ls .loki/difference/

For a more integrated experience, use the Loki Storybook addon that shows pass/fail status alongside each story in the Storybook UI:

npm install --save-dev @loki/storybook-addon
// .storybook/main.js
module.exports = {
  addons: ['@loki/storybook-addon'],
};

CI Integration

name: Visual Regression

on: [pull_request]

jobs:
  loki:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: npm
      
      - run: npm ci
      
      - name: Build Storybook
        run: npm run build-storybook -- --quiet
      
      - name: Run Loki visual regression tests
        run: npx loki test --requireReference --reactUri file:./storybook-static
      
      - name: Upload diffs on failure
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: loki-diffs
          path: .loki/difference/

The --reactUri file:./storybook-static flag runs against a built Storybook (no server needed), which is faster and more reliable in CI than starting a dev server.

--requireReference fails if any story has no reference screenshot — catches new components added without updating references.

Updating References in CI

When a PR intentionally changes component appearance:

# Run locally
npx loki update

<span class="hljs-comment"># Commit the updated references
git add .loki/reference/
git commit -m <span class="hljs-string">"chore: update loki visual references for new button design"
git push

References are regular PNG files in .loki/reference/ — they show in code review diffs. Reviewers can see exactly which components changed visually.

Excluding Stories

Skip specific stories from Loki testing:

// In the story file
export const AnimatedComponent = {
  args: { /* ... */ },
  parameters: {
    loki: { skip: true },
  },
};

Or configure globally:

{
  "loki": {
    "skipStories": ["Components/Loading/Spinner", "Experimental/*"]
  }
}

Performance

For large Storybook projects (100+ stories), Loki can be slow. Speed up:

{
  "loki": {
    "chromeFlags": "--disable-dev-shm-usage --no-sandbox",
    "configurations": {
      "chrome.laptop": {
        "target":         "chrome.docker",
        "concurrency":    4
      }
    }
  }
}

Parallel screenshot capture with concurrency: 4 reduces total time by ~4x for large story sets.

Loki vs Percy vs Chromatic

Feature Loki Percy Chromatic
Self-hosted Yes No No
References in git Yes No No
Multi-browser No Yes No
Review UI Local files Hosted Hosted
Storybook native Yes Yes Yes
Offline capable Yes No No
Price Free Usage-based Usage-based

Loki is the right choice for teams that:

  • Want references version-controlled alongside component code
  • Can't use external services (security constraints, air-gapped environments)
  • Don't need multi-browser comparison
  • Want a zero-dependency, zero-cost solution

Summary

Loki provides Storybook visual regression testing with minimal setup:

  • References stored in .loki/reference/ alongside code
  • updatetestapprove workflow
  • Docker target for consistent cross-machine screenshots
  • Story-level exclusion and threshold configuration
  • CI-native with artifact upload for diff review

For teams already invested in Storybook and wanting visual regressions caught in PR review, Loki adds the visual layer without requiring external accounts or services.

Read more