Reusable Modal & Wizard Component
Nebula.modal() is a composable mixin — spread it into any Alpine.js component to get animated modals, multi-step wizards, keyboard handling, and focus trapping with zero boilerplate.
Architecture: Mixin, Not Standalone
Unlike traditional Magento modals that are self-contained widgets, Nebula.modal() is a plain JavaScript function that returns an object of Alpine.js reactive properties and methods. You compose it into your own component with the spread operator.
This means any component can become a modal — a product option wizard, a confirmation dialog, a file picker — without inheritance, event buses, or framework lock-in. Your component owns its data; the mixin only adds modal-specific state and behavior.
Alpine.data('myComponent', function(config) {
return {
...Nebula.modal({ size: 'lg', steps: ['Pick', 'Configure'] }),
// Your component's own data and methods
items: config.items || [],
save() { /* ... */ }
};
});Before & After
Traditional approach
- - 200+ lines of boilerplate per modal
- - Manual DOM manipulation for open/close
- - Custom z-index management per modal
- - No wizard/step support built-in
- - Escape key and backdrop click wired manually
- - Transition animations hand-coded each time
- - Scroll lock and focus trap forgotten
With Nebula.modal()
- + One-line spread:
...Nebula.modal(config) - + Reactive
modalOpendrives everything - + Consistent z-index, backdrop blur, animations
- + Built-in multi-step wizard with guards
- + Escape key and backdrop click-to-close automatic
- + Fade + scale transitions via Alpine
x-transition - + Auto focus-trap on first input after open
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
| size | string | 'lg' | Modal width — sm, md, lg, xl, or full |
| steps | string[] | [] | Step labels for wizard mode. Empty = simple modal. |
| title | string | '' | Static modal title. Override dynamically via modalTitle. |
| onOpen | Function|null | null | Callback after modal opens. this = component. |
| onClose | Function|null | null | Callback after modal closes. this = component. |
Properties & Methods
Reactive Properties
| modalOpen | boolean | Whether the modal is visible. |
| modalStep | number | Current wizard step (zero-based). |
| modalSteps | string[] | Array of step labels. |
| modalSize | string | Current size key. |
| modalTitle | string | Current title. Writable — override dynamically. |
Methods
| openModal() | Opens modal, resets to step 0, fires onOpen callback, dispatches nebula-modal:open event. |
| closeModal() | Closes modal, fires onClose callback, dispatches nebula-modal:close event. |
| nextModalStep(canProceed?) | Advances to next step. Optional guard function must return true to proceed. |
| prevModalStep() | Goes back one step. No-op on step 0. |
| goToModalStep(index) | Jumps to a specific step by zero-based index. |
Computed Getters
| modalSizeClass | string | Tailwind max-width class (e.g. 'max-w-3xl'). |
| isFirstModalStep | boolean | True when modalStep === 0. |
| isLastModalStep | boolean | True on last step or when no steps configured. |
| hasModalSteps | boolean | True when steps array is non-empty. |
| currentModalStepLabel | string | Label of current step, or '' if no steps. |
| modalStepCount | number | Total number of wizard steps. |
HTML Template Pattern
The modal requires a specific HTML structure for transitions to work. The backdrop fades independently while the card scales in/out. The card uses flex-column with a max height so the content scrolls while header and footer stay pinned.
<!-- Place inside your x-data root element -->
<div x-show="modalOpen" x-cloak
class="fixed inset-0 z-[9999] flex items-center justify-center"
@keydown.escape.window="closeModal()">
<!-- Backdrop (fade transition) -->
<div x-show="modalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
@click="closeModal()"></div>
<!-- Modal card (scale transition) -->
<div :class="modalSizeClass"
class="relative w-full max-h-[90vh] flex flex-col rounded-2xl bg-white shadow-2xl"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop>
<!-- Sticky header -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-200
px-8 py-5 rounded-t-2xl shrink-0">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-gray-900"
x-text="modalTitle"></h2>
<button @click="closeModal()" type="button">X</button>
</div>
<!-- Step indicators (wizard mode only) -->
</div>
<!-- Scrollable content -->
<div class="px-8 py-6 overflow-y-auto flex-1">
<div x-show="modalStep === 0">Step 1 content</div>
<div x-show="modalStep === 1">Step 2 content</div>
</div>
<!-- Sticky footer -->
<div class="sticky bottom-0 bg-white border-t border-gray-200
px-8 py-4 rounded-b-2xl flex items-center
justify-between shrink-0">
<button @click="isFirstModalStep ? closeModal() : prevModalStep()"
x-text="isFirstModalStep ? 'Cancel' : 'Back'"></button>
<button @click="isLastModalStep ? save() : nextModalStep()"
x-text="isLastModalStep ? 'Save' : 'Next'"></button>
</div>
</div>
</div>flex flex-col + max-h-[90vh] so content scrolls while header/footer stay pinned. @click.stop on the card prevents backdrop click-to-close when clicking inside.Creating a Custom Wizard
Define your Alpine component with the mixin
(function() {
if (window._myWizardRegistered) return;
window._myWizardRegistered = true;
var register = function() {
Alpine.data('myWizard', function(config) {
return {
...Nebula.modal({
size: 'lg',
steps: ['Choose Type', 'Configure', 'Review'],
onClose: function () {
this.selectedType = '';
this.itemName = '';
}
}),
items: config.existing || [],
selectedType: '',
itemName: '',
selectType(type) {
this.selectedType = type;
this.nextModalStep();
},
saveItem() {
this.items.push({
type: this.selectedType,
name: this.itemName
});
this.closeModal();
}
};
});
};
if (window.Alpine) { register(); }
else { document.addEventListener('alpine:init', register); }
})();Build the template with the standard HTML pattern
Use x-show="modalStep === N" to show/hide each step's content. The footer buttons use the computed getters to adapt labels and behavior.
<div x-data="myWizard({existing: []})">
<button @click="openModal()" type="button"
class="rounded-lg bg-indigo-600 px-4 py-2 text-white">
Add Item
</button>
<!-- Modal (use the full HTML pattern from above) -->
<div x-show="modalOpen" x-cloak
class="fixed inset-0 z-[9999] flex items-center justify-center"
@keydown.escape.window="closeModal()">
<!-- backdrop + card + header with steps + content + footer -->
</div>
</div>Use guarded transitions between steps
Pass a validation function to nextModalStep() to prevent advancing until conditions are met.
<!-- In the footer "Next" button -->
<button @click="nextModalStep(function() { return selectedType !== ''; })"
:disabled="selectedType === ''"
class="rounded-lg bg-indigo-600 px-5 py-2 text-white
disabled:opacity-40 disabled:cursor-not-allowed">
Next
</button>Wire up the onClose callback for cleanup
The onClose callback runs after every close (Escape, backdrop click, or explicit closeModal()). Use it to reset wizard state so re-opening starts fresh.
...Nebula.modal({
steps: ['Pick', 'Configure'],
onClose: function () {
this.selectedType = '';
this.itemName = '';
// modalStep is auto-reset to 0 on next openModal()
}
})Size Variants
Simple Modal (No Steps)
Omit the steps config for confirmation dialogs and single-screen edit modals. When steps is empty, hasModalSteps is false and isLastModalStep is true.
Alpine.data('confirmDelete', function() {
return {
...Nebula.modal({ size: 'sm', title: 'Confirm Delete' }),
targetId: null,
confirmDelete(id) {
this.targetId = id;
this.openModal();
},
doDelete() {
// Perform delete with this.targetId ...
this.closeModal();
}
};
});hasModalSteps is false. Use <template x-if="hasModalSteps"> around the step indicator markup so it renders nothing for simple modals.Custom Events
| Event | Fired When | Bubbles |
|---|---|---|
| nebula-modal:open | After openModal() completes (next tick) | Yes |
| nebula-modal:close | After closeModal() completes | Yes |
<!-- From a parent or sibling element -->
<div @nebula-modal:open.window="console.log('A modal opened')"
@nebula-modal:close.window="console.log('A modal closed')">
</div>