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 (
playfunction).
Storybook’s play function runs after the story renders and receives helpers like canvas and userEvent, making it perfect for testing UI behavior. citeturn1view0turn1view1
1) Install Storybook (Vue 3 + Vite)
From your Laravel project root:
npx storybook@latest init
When Storybook detects Vite, it uses the Vite builder and can be customized via viteFinal if needed.
Start it:
npm run storybook
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
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;
The stories glob is how Storybook discovers .stories.* files, and you can point it to any folder you want. citeturn2view1
.storybook/preview.ts (recommended for modals/overlays)
Overlays look best in fullscreen layout:
export default {
parameters: {
layout: "fullscreen",
},
};
3) The component: a light Modal with per-component CSS
stories/ui/Modal/Modal.vue
This modal:
- Uses
Teleportto render above everything - Supports ESC close + overlay close
- Emits
update:openso 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>
Typing gotcha: if TypeScript shows
Expected 0 arguments, but got 3onwatch(...), it usually means your IDE auto-imported the wrongwatch. Renaming the import towatch as vueWatchmakes 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;
}
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
playfunction can automate: click Open, assert modal exists, click overlay, assert modal closed
Storybook provides userEvent via the play context. citeturn1view0turn1view1
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();
},
};
Note: we use
screen(global queries) because the modal is teleported todocument.body. The Storybook docs cover bothcanvasqueries 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";
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";
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)