DEV Community

A0mineTV
A0mineTV

Posted on

Storybook in a Laravel + Inertia (Vue 3) app: building a modal + interaction tests

If you’re building a Laravel + Inertia (Vue 3) app, you’re probably shipping UI components fast… and occasionally breaking them in subtle ways (spacing, states, weird “only happens on one page” cases).

That’s why I like adding Storybook: it gives you a dedicated workshop to build, preview, document, and test UI components in isolation, while Laravel stays focused on the backend.

In this post, I’ll show you a clean setup where everything lives inside /stories (components + CSS + stories), plus a concrete example: a light-themed modal with per-component CSS and an interaction test.


Why Storybook (even in a Laravel project)?

  • Develop components without navigating your app.
  • Cover “hard-to-reach” UI states (empty states, long content, errors).
  • Add interaction tests directly in the story (play function).

Storybook’s play function runs after the story renders and receives helpers like canvas and userEvent, making it perfect for testing UI behavior. citeturn1view0turn1view1


1) Install Storybook (Vue 3 + Vite)

From your Laravel project root:

npx storybook@latest init
Enter fullscreen mode Exit fullscreen mode

When Storybook detects Vite, it uses the Vite builder and can be customized via viteFinal if needed.

Start it:

npm run storybook
Enter fullscreen mode Exit fullscreen mode

2) Keep everything in /stories

Here’s the structure we’ll use:

.ststorybook/
  main.ts
  preview.ts

stories/
  ui/
    Button/
      Button.vue
      Button.css
    Input/
      Input.vue
      Input.css
    Modal/
      Modal.vue
      Modal.css
      Modal.stories.ts
Enter fullscreen mode Exit fullscreen mode

Then configure Storybook to load stories from that directory.

.storybook/main.ts

import type { StorybookConfig } from "@storybook/vue3-vite";

const config: StorybookConfig = {
  stories: ["../stories/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
  addons: ["@storybook/addon-essentials"],
  framework: {
    name: "@storybook/vue3-vite",
    options: {},
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

The stories glob is how Storybook discovers .stories.* files, and you can point it to any folder you want. citeturn2view1

.storybook/preview.ts (recommended for modals/overlays)

Overlays look best in fullscreen layout:

export default {
  parameters: {
    layout: "fullscreen",
  },
};
Enter fullscreen mode Exit fullscreen mode

3) The component: a light Modal with per-component CSS

stories/ui/Modal/Modal.vue

This modal:

  • Uses Teleport to render above everything
  • Supports ESC close + overlay close
  • Emits update:open so the parent controls state
  • Has optional footer slot
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, watch as vueWatch } from "vue";

type CloseReason = "overlay" | "esc" | "button";

const props = withDefaults(
  defineProps<{
    open: boolean;
    title?: string;
    closeOnOverlay?: boolean;
    closeOnEsc?: boolean;
    showClose?: boolean;
    lockScroll?: boolean;
    maxWidth?: "sm" | "md" | "lg" | "xl";
  }>(),
  {
    closeOnOverlay: true,
    closeOnEsc: true,
    showClose: true,
    lockScroll: true,
    maxWidth: "md",
  }
);

const emit = defineEmits<{
  (e: "update:open", value: boolean): void;
  (e: "close", reason: CloseReason): void;
}>();

let previousActiveEl: HTMLElement | null = null;
let previousBodyOverflow: string | null = null;

const titleId = `ui-modal-title-${Math.random().toString(36).slice(2)}`;

const widthClass = computed(() => {
  switch (props.maxWidth) {
    case "sm":
      return "ui-modal--sm";
    case "md":
      return "ui-modal--md";
    case "lg":
      return "ui-modal--lg";
    case "xl":
      return "ui-modal--xl";
    default:
      return "ui-modal--md";
  }
});

function close(reason: CloseReason) {
  emit("update:open", false);
  emit("close", reason);
}

function onOverlayClick() {
  if (!props.closeOnOverlay) return;
  close("overlay");
}

function onKeydown(e: KeyboardEvent) {
  if (!props.open) return;
  if (e.key === "Escape" && props.closeOnEsc) {
    e.preventDefault();
    close("esc");
  }
}

function lockBodyScroll() {
  if (!props.lockScroll) return;
  previousBodyOverflow = document.body.style.overflow;
  document.body.style.overflow = "hidden";
}

function unlockBodyScroll() {
  if (!props.lockScroll) return;
  document.body.style.overflow = previousBodyOverflow ?? "";
  previousBodyOverflow = null;
}

async function focusPanel() {
  await nextTick();
  const panel = document.querySelector(".ui-modal__panel") as HTMLElement | null;
  panel?.focus();
}

vueWatch(
  () => props.open,
  async (isOpen) => {
    if (isOpen) {
      previousActiveEl = document.activeElement as HTMLElement | null;
      window.addEventListener("keydown", onKeydown);
      lockBodyScroll();
      await focusPanel();
    } else {
      window.removeEventListener("keydown", onKeydown);
      unlockBodyScroll();
      previousActiveEl?.focus?.();
      previousActiveEl = null;
    }
  },
  { immediate: true }
);

onBeforeUnmount(() => {
  window.removeEventListener("keydown", onKeydown);
  unlockBodyScroll();
});
</script>

<template>
  <Teleport to="body">
    <Transition name="ui-fade">
      <div v-if="open" class="ui-modal" aria-hidden="false">
        <div class="ui-modal__overlay" data-testid="modal-overlay" @click="onOverlayClick" />

        <Transition name="ui-pop">
          <div
            class="ui-modal__panel"
            :class="widthClass"
            role="dialog"
            aria-modal="true"
            :aria-labelledby="title ? titleId : undefined"
            tabindex="-1"
          >
            <header class="ui-modal__header">
              <h2 v-if="title" :id="titleId" class="ui-modal__title">{{ title }}</h2>

              <button
                v-if="showClose"
                class="ui-modal__close"
                type="button"
                aria-label="Close dialog"
                @click="close('button')"
              ></button>
            </header>

            <section class="ui-modal__body">
              <slot />
            </section>

            <footer v-if="$slots.footer" class="ui-modal__footer">
              <slot name="footer" />
            </footer>
          </div>
        </Transition>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped src="./Modal.css"></style>
Enter fullscreen mode Exit fullscreen mode

Typing gotcha: if TypeScript shows Expected 0 arguments, but got 3 on watch(...), it usually means your IDE auto-imported the wrong watch. Renaming the import to watch as vueWatch makes it explicit.

stories/ui/Modal/Modal.css (light theme + nicer typography)

.ui-modal {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: grid;
  place-items: center;
  padding: 24px;
}

/* Overlay */
.ui-modal__overlay {
  position: absolute;
  inset: 0;
  background: rgba(15, 23, 42, 0.42);
}

/* Panel */
.ui-modal__panel {
  position: relative;
  width: min(720px, 100%);
  max-height: min(84vh, 900px);
  background: #ffffff;
  color: #0f172a;
  border: 1px solid rgba(15, 23, 42, 0.10);
  border-radius: 16px;
  box-shadow: 0 18px 70px rgba(15, 23, 42, 0.18);
  overflow: hidden;
  outline: none;

  /* typography */
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
    "Apple Color Emoji", "Segoe UI Emoji";
  font-size: 14px;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.ui-modal__panel button,
.ui-modal__panel input {
  font: inherit;
}

/* Sizes */
.ui-modal--sm { width: min(440px, 100%); }
.ui-modal--md { width: min(640px, 100%); }
.ui-modal--lg { width: min(820px, 100%); }
.ui-modal--xl { width: min(1020px, 100%); }

/* Header */
.ui-modal__header {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 12px;
  padding: 18px 18px 10px 18px;
  border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}

.ui-modal__title {
  margin: 0;
  font-size: 18px;
  line-height: 1.2;
  letter-spacing: 0.2px;
}

/* Close button */
.ui-modal__close {
  appearance: none;
  border: 1px solid rgba(15, 23, 42, 0.10);
  background: rgba(15, 23, 42, 0.04);
  border-radius: 10px;
  padding: 8px 10px;
  cursor: pointer;
  line-height: 1;
}

.ui-modal__close:hover {
  background: rgba(15, 23, 42, 0.08);
}

/* Body / footer */
.ui-modal__body {
  padding: 16px 18px;
  overflow: auto;
}

.ui-modal__footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 14px 18px 18px 18px;
  border-top: 1px solid rgba(15, 23, 42, 0.08);
}

/* Transitions */
.ui-fade-enter-active,
.ui-fade-leave-active {
  transition: opacity 160ms ease;
}
.ui-fade-enter-from,
.ui-fade-leave-to {
  opacity: 0;
}

.ui-pop-enter-active {
  transition: transform 160ms ease, opacity 160ms ease;
}
.ui-pop-leave-active {
  transition: transform 130ms ease, opacity 130ms ease;
}
.ui-pop-enter-from {
  transform: translateY(10px) scale(0.98);
  opacity: 0;
}
.ui-pop-leave-to {
  transform: translateY(6px) scale(0.99);
  opacity: 0;
}
Enter fullscreen mode Exit fullscreen mode

4) The Story: real-life scenarios + interaction tests

Here’s the key idea:

  • A story can render a button that opens the modal
  • The story controls the modal state (open)
  • The play function can automate: click Open, assert modal exists, click overlay, assert modal closed

Storybook provides userEvent via the play context. citeturn1view0turn1view1

And expect comes from the storybook/test module (not @storybook/test).

stories/ui/Modal/Modal.stories.ts

import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { ref } from "vue";
import { expect, screen } from "storybook/test";

import Modal from "./Modal.vue";
import "../Button/Button.css"; // optional if your buttons are in another folder
import "./Modal.css";

const meta = {
  title: "UI/Modal",
  component: Modal,
  args: {
    title: "Delete item",
    open: false,
    maxWidth: "sm",
    closeOnOverlay: true,
    closeOnEsc: true,
    showClose: true,
    lockScroll: true,
  },
} satisfies Meta<typeof Modal>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Basic: Story = {
  render: (args) => ({
    components: { Modal },
    setup() {
      const open = ref(false);
      const openModal = () => (open.value = true);
      const closeModal = () => (open.value = false);
      return { args, open, openModal, closeModal };
    },
    template: `
      <div style="padding:24px;">
        <button type="button" class="ui-modal__close" style="padding:10px 14px;" @click="openModal">
          Open modal
        </button>

        <Modal v-bind="args" :open="open" @update:open="(v) => (open = v)">
          <p>Are you sure you want to delete this item? This action cannot be undone.</p>

          <template #footer>
            <button type="button" class="ui-modal__close" style="padding:10px 14px;" @click="closeModal">
              Cancel
            </button>
            <button type="button" class="ui-modal__close" style="padding:10px 14px; font-weight:600;" @click="closeModal">
              Confirm
            </button>
          </template>
        </Modal>
      </div>
    `,
  }),

  play: async ({ canvas, userEvent }) => {
    // 1) open
    await userEvent.click(canvas.getByRole("button", { name: /open modal/i }));

    // Teleport renders into document.body, so we use screen (global)
    const dialog = await screen.findByRole("dialog");
    await expect(dialog).toBeVisible();

    // 2) close via overlay
    await userEvent.click(screen.getByTestId("modal-overlay"));
    await expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
  },
};
Enter fullscreen mode Exit fullscreen mode

Note: we use screen (global queries) because the modal is teleported to document.body. The Storybook docs cover both canvas queries and interaction testing helpers.


5) Troubleshooting (the stuff that actually bites)

“Failed to resolve import @storybook/test”

If you’re on a modern Storybook setup, you don’t need @storybook/test. Use:

import { expect } from "storybook/test";
Enter fullscreen mode Exit fullscreen mode

That’s the documented way to get expect (and fn) for interaction testing

“Expected 0 arguments, but got 3” on watch(...)

This is almost always a bad auto-import. Rename it:

import { watch as vueWatch } from "vue";
Enter fullscreen mode Exit fullscreen mode

Next steps

Now that the modal is stable, I’d add stories for:

  • Long content (internal scrolling)
  • Form in modal (errors, disabled state, submit)
  • No footer (layout)
  • closeOnOverlay = false (prevent accidental dismiss)

And then repeat the same approach for real Inertia components like:

  • TaskCard, KanbanColumn, filters, empty states, etc.

If you build UI in Laravel + Inertia, Storybook is one of the best “quality-of-life” additions you can make: less guesswork, fewer regressions, and a living UI catalog for free.

Top comments (0)