Modal Components

Dialog boxes that appear on top of the main content. Perfect for confirmations, forms, alerts, and focused user interactions.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["dialog", "template"];
  static values = {
    open: { type: Boolean, default: false }, // Whether the modal is open
    lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the modal content
    turboFrameSrc: { type: String, default: "" }, // URL for the turbo frame
    preventDismiss: { type: Boolean, default: false }, // Whether to prevent the modal from being dismissed
  };

  connect() {
    if (this.openValue) this.open();
    this.boundBeforeCache = this.beforeCache.bind(this);
    document.addEventListener("turbo:before-cache", this.boundBeforeCache);
    this.contentLoaded = false;
    this.isBouncing = false;

    // Add event listener for when dialog is closed by any means (including Escape key)
    this.dialogTarget.addEventListener("close", this.handleDialogClose.bind(this));

    // Prevent Escape key from closing if preventDismiss is true
    this.dialogTarget.addEventListener("cancel", this.handleDialogCancel.bind(this));

    // Additional keydown listener for better escape key handling
    this.boundHandleKeydown = this.handleKeydown.bind(this);
    this.dialogTarget.addEventListener("keydown", this.boundHandleKeydown);
  }

  disconnect() {
    document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
    this.dialogTarget.removeEventListener("close", this.handleDialogClose.bind(this));
    this.dialogTarget.removeEventListener("cancel", this.handleDialogCancel.bind(this));
    this.dialogTarget.removeEventListener("keydown", this.boundHandleKeydown);
  }

  async open() {
    // If lazy loading is enabled and content hasn't been loaded yet, load it now
    if (this.lazyLoadValue && !this.contentLoaded) {
      await this.#loadTemplateContent();
      this.contentLoaded = true;
    }

    // Calculate and apply scrollbar compensation
    const scrollbarWidth = this.getScrollbarWidth();
    if (scrollbarWidth > 0) {
      document.body.style.paddingRight = `${scrollbarWidth}px`;
    }

    this.dialogTarget.showModal();

    // On touch devices, remove initial focus to prevent awkward focus rings
    if (this.isTouchDevice()) {
      // Find the currently focused element within the dialog
      const focusedElement = this.dialogTarget.querySelector(":focus");
      if (focusedElement) {
        focusedElement.blur();
      }
    }
  }

  // Allows for a closing animation since display transitions don't work yet
  close() {
    this.dialogTarget.setAttribute("closing", "");

    Promise.all(this.dialogTarget.getAnimations().map((animation) => animation.finished)).then(() => {
      this.dialogTarget.removeAttribute("closing");
      this.dialogTarget.close();

      // Remove scrollbar compensation
      document.body.style.paddingRight = "";

      // Reset bouncing flag
      this.isBouncing = false;
    });
  }

  backdropClose(event) {
    if (event.target.nodeName == "DIALOG") {
      if (this.preventDismissValue) {
        this.bounce();
      } else {
        this.close();
      }
    }
  }

  // For showing non-modally
  show() {
    this.dialogTarget.show();
  }

  hide() {
    this.close();
  }

  beforeCache() {
    this.close();
  }

  // Calculate actual scrollbar width
  getScrollbarWidth() {
    // Create a temporary div to measure scrollbar width
    const outer = document.createElement("div");
    outer.style.visibility = "hidden";
    outer.style.overflow = "scroll";
    outer.style.msOverflowStyle = "scrollbar"; // Force scrollbars on IE/Edge
    document.body.appendChild(outer);

    const inner = document.createElement("div");
    outer.appendChild(inner);

    const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
    outer.parentNode.removeChild(outer);

    return scrollbarWidth;
  }

  async #loadTemplateContent() {
    // Find the container in the dialog to append content to
    const container = this.dialogTarget.querySelector("[data-modal-content]") || this.dialogTarget;

    // Check if we should use Turbo Frame lazy loading
    if (this.turboFrameSrcValue) {
      // Look for a turbo-frame in the container
      let turboFrame = container.querySelector("turbo-frame");

      if (!turboFrame) {
        // Create a turbo-frame if it doesn't exist
        turboFrame = document.createElement("turbo-frame");
        turboFrame.id = "modal-lazy-content";

        // Clear any loading indicators or placeholder content
        container.innerHTML = "";
        container.appendChild(turboFrame);
      }

      // Set the src to trigger the lazy load
      turboFrame.src = this.turboFrameSrcValue;

      // Wait for the turbo-frame to load
      return new Promise((resolve) => {
        const handleLoad = () => {
          turboFrame.removeEventListener("turbo:frame-load", handleLoad);
          resolve();
        };

        turboFrame.addEventListener("turbo:frame-load", handleLoad);

        // Fallback timeout in case the frame doesn't load
        setTimeout(() => {
          turboFrame.removeEventListener("turbo:frame-load", handleLoad);
          resolve();
        }, 5000);
      });
    } else if (this.hasTemplateTarget) {
      // Use template-based lazy loading (existing behavior)
      const templateContent = this.templateTarget.content.cloneNode(true);

      // Clear any loading indicators or placeholder content
      container.innerHTML = "";

      // Append the template content
      container.appendChild(templateContent);
    }
  }

  // Handle cleanup when dialog is closed by any means
  handleDialogClose() {
    // Remove scrollbar compensation
    document.body.style.paddingRight = "";

    // Ensure the closing attribute is removed
    this.dialogTarget.removeAttribute("closing");

    // Reset bouncing flag
    this.isBouncing = false;
  }

  // Handle keydown events
  handleKeydown(event) {
    if (event.key === "Escape" && this.preventDismissValue) {
      event.preventDefault();
      event.stopPropagation();
      event.stopImmediatePropagation();
      this.bounce();
      return false;
    }
  }

  // Handle cancel event (Escape key)
  handleDialogCancel(event) {
    if (this.preventDismissValue) {
      event.preventDefault();
      event.stopPropagation();
      this.bounce();
      return false;
    }
  }

  // Add bounce animation to indicate modal won't close
  bounce() {
    // Prevent multiple bounces in quick succession
    if (this.isBouncing) return;

    this.isBouncing = true;

    // For a more pronounced effect, we can combine with scale
    this.dialogTarget.classList.add("scale-105", "transition-transform");

    setTimeout(() => {
      this.dialogTarget.classList.remove("scale-105");
      this.dialogTarget.classList.add("scale-100");

      setTimeout(() => {
        // Remove all animation classes
        this.dialogTarget.classList.remove("scale-100", "transition-transform");

        // Allow bouncing again after a short cooldown
        setTimeout(() => {
          this.isBouncing = false;
        }, 200); // Additional cooldown to prevent rapid bouncing
      }, 150);
    }, 150);
  }

  // Check if the device supports touch
  isTouchDevice() {
    return "ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
  }
}

2. Custom CSS

Here are the custom CSS classes that we used on Rails Blocks to style the modal components. You can copy and paste these into your own CSS file to style & personalize your modals. This is the same CSS applied to the slideover components.

/* Dialog */

/* Firefox has a bug with backdrop, so we can use a box-shadow instead */
dialog.modal {
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.5);
}

dialog.slideover {
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.5);
}

dialog.max-w-full {
  box-shadow: none;
}

dialog::backdrop {
  background: none;
}

/* Modal animations */
dialog.modal:not(.max-w-full)[open] {
  animation: fadeIn 200ms forwards, scaleIn 200ms forwards;
}

dialog.modal:not(.max-w-full)[closing] {
  animation: fadeOut 200ms forwards, scaleOut 200ms forwards;
}

/* Fullscreen modal animations - fade only */
dialog.modal.max-w-full[open] {
  animation: fadeIn 200ms forwards;
}

dialog.modal.max-w-full[closing] {
  animation: fadeOut 200ms forwards;
}

/* Center modals */
dialog.modal {
  margin: auto;
  position: fixed;
  inset: 0;
  align-items: center;
  justify-content: center;
}

/* Slideover animations */
dialog.slideover[open] {
  animation: fadeIn 200ms forwards ease-in-out, slide-in-from-right 200ms forwards ease-in-out;
}

dialog.slideover[closing] {
  pointer-events: none;
  animation: fadeOut 200ms forwards ease-in-out, slide-out-to-right 200ms forwards ease-in-out;
}

/* Slideover animations for top */
dialog.slideover-top[open] {
  animation: fadeIn 200ms forwards ease-in-out, slide-in-from-top 200ms forwards ease-in-out;
}

dialog.slideover-top[closing] {
  animation: fadeOut 200ms forwards ease-in-out, slide-out-to-top 200ms forwards ease-in-out;
}

/* Slideover animations for bottom */
dialog.slideover-bottom[open] {
  animation: fadeIn 200ms forwards ease-in-out, slide-in-from-bottom 200ms forwards ease-in-out;
}

dialog.slideover-bottom[closing] {
  animation: fadeOut 200ms forwards ease-in-out, slide-out-to-bottom 200ms forwards ease-in-out;
}

/* Slideover animations for left */
dialog.slideover-left[open] {
  animation: fadeIn 200ms forwards ease-in-out, slide-in-from-left 200ms forwards ease-in-out;
}

dialog.slideover-left[closing] {
  animation: fadeOut 200ms forwards ease-in-out, slide-out-to-left 200ms forwards ease-in-out;
}

/* Slideover animations for right */
dialog.slideover-right[open] {
  animation: fadeIn 200ms forwards ease-in-out, slide-in-from-right 200ms forwards ease-in-out;
}

dialog.slideover-right[closing] {
  animation: fadeOut 200ms forwards ease-in-out, slide-out-to-right 200ms forwards ease-in-out;
}

body {
  scrollbar-gutter: stable;
  overflow-y: scroll;
}

/* Prevent scrolling while dialog is open */
body:has(dialog.modal[open]) {
  overflow: hidden;
}

body:has(dialog.slideover[open]) {
  overflow: hidden;
}

dialog.modal {
  cursor: auto;
}

/* Keyframes for fade animations */
@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

/* Keyframes for new animations */
@keyframes slide-in-from-top {
  from {
    transform: translateY(-100%);
  }
  to {
    transform: translateY(0);
  }
}

@keyframes slide-out-to-top {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(-100%);
  }
}

@keyframes slide-in-from-bottom {
  from {
    transform: translateY(100%);
  }
  to {
    transform: translateY(0);
  }
}

@keyframes slide-out-to-bottom {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(100%);
  }
}

@keyframes slide-in-from-left {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(0);
  }
}

@keyframes slide-out-to-left {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-100%);
  }
}

@keyframes slide-in-from-right {
  from {
    transform: translateX(100%);
  }
  to {
    transform: translateX(0);
  }
}

@keyframes slide-out-to-right {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(100%);
  }
}

dialog[data-floating-select-target="menu"] {
  opacity: 0;
}

dialog[data-floating-select-target="menu"][open] {
  opacity: 1;
}

/* Add new keyframes for scale animations */
@keyframes scaleIn {
  from {
    transform: scale(0.95);
  }
  to {
    transform: scale(1);
  }
}

@keyframes scaleOut {
  from {
    transform: scale(1);
  }
  to {
    transform: scale(0.95);
  }
}

/* Add specific box-shadow handling for slideover directions */
dialog.slideover-top,
dialog.slideover-bottom {
  box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.6);
}

Examples

Basic Modal

A simple modal with a trigger button, content, and actions.

Welcome to Rails Blocks

This is a basic modal component. It can contain any content you need - text, images, forms, or other components.

Click the close button in the top right, press ESC, or click outside the modal to close it.

<div data-controller="modal">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    Open Modal
  </button>

  <dialog class="modal max-w-[100vw-2rem] sm:max-w-md bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
        <span class="sr-only">Close</span>
      </button>

      <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Welcome to Rails Blocks</h2>
      <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
        This is a basic modal component. It can contain any content you need - text, images, forms, or other components.
      </p>
      <p class="text-sm text-neutral-600 dark:text-neutral-400">
        Click the close button in the top right, press ESC, or click outside the modal to close it.
      </p>

      <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Close
        </button>
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Get Started
        </button>
      </div>
    </div>
  </dialog>
</div>

Confirm Modal

A confirmation modal typically used for destructive actions. This modal prevents the user from closing it by clicking the backdrop or pressing the escape key.

Delete Account?

Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently removed.

<div data-controller="modal" data-modal-prevent-dismiss-value="true">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-red-300/30 bg-red-600 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    Delete Account
  </button>

  <dialog class="modal max-w-[100vw-2rem] sm:max-w-sm bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <div class="flex items-start gap-3">
        <div>
          <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Delete Account?</h2>
          <p class="text-sm text-neutral-600 dark:text-neutral-400">
            Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently removed.
          </p>
        </div>
      </div>

      <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Cancel
        </button>
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-red-300/30 bg-red-600 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Delete Account
        </button>
      </div>
    </div>
  </dialog>
</div>

Modals come in various sizes: small, medium, large, extra large, and more.

Small Modal

This is a small modal, perfect for brief confirmations or simple messages.

Medium Modal (Default)

This is a medium-sized modal, the default size. It works well for most content types including forms and detailed information.

Large Modal

This is a large modal. It provides more space for complex forms, detailed content, or when you need to display multiple sections of information.

Extra Large Modal

This is an extra large modal. Use this size when you need substantial space for content like data tables, multi-step forms, or detailed documentation.

<div class="flex flex-wrap gap-2">
  <%# Small Modal %>
  <div data-controller="modal">
    <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
      Small Modal
    </button>

    <dialog class="modal bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 max-w-sm border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
      <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
        <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
          <span class="sr-only">Close</span>
        </button>

        <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Small Modal</h2>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">This is a small modal, perfect for brief confirmations or simple messages.</p>

        <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
            Close
          </button>
        </div>
      </div>
    </dialog>
  </div>

  <%# Medium Modal (Default) %>
  <div data-controller="modal">
    <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
      Medium Modal
    </button>

    <dialog class="modal max-w-[100vw-2rem] sm:max-w-md bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
      <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
        <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
          <span class="sr-only">Close</span>
        </button>

        <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Medium Modal (Default)</h2>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">This is a medium-sized modal, the default size. It works well for most content types including forms and detailed information.</p>

        <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
            Close
          </button>
        </div>
      </div>
    </dialog>
  </div>

  <%# Large Modal %>
  <div data-controller="modal">
    <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
      Large Modal
    </button>

    <dialog class="modal max-w-[100vw-2rem] sm:max-w-lg bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
      <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
        <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
          <span class="sr-only">Close</span>
        </button>

        <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Large Modal</h2>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">This is a large modal. It provides more space for complex forms, detailed content, or when you need to display multiple sections of information.</p>

        <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
            Close
          </button>
        </div>
      </div>
    </dialog>
  </div>

  <%# Extra Large Modal %>
  <div data-controller="modal">
    <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
      XL Modal
    </button>

    <dialog class="modal max-w-[100vw-2rem] sm:max-w-xl bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
      <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
        <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
          <span class="sr-only">Close</span>
        </button>

        <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Extra Large Modal</h2>
        <p class="text-sm text-neutral-600 dark:text-neutral-400">This is an extra large modal. Use this size when you need substantial space for content like data tables, multi-step forms, or detailed documentation.</p>

        <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
          <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
            Close
          </button>
        </div>
      </div>
    </dialog>
  </div>
</div>

Fullscreen Modal

A modal that takes up the entire viewport.

Fullscreen Modal Experience

This modal takes up the entire viewport, providing an immersive experience perfect for media viewers, complex workflows, or when you need the user's complete attention.

Full Focus

Eliminates distractions for better user focus

Maximum Space

Utilize entire screen for complex content

Immersive

Perfect for media and presentations

<div data-controller="modal">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    <svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><polyline points="10.75 2.75 15.25 2.75 15.25 7.25"></polyline><line x1="15.25" y1="2.75" x2="10.75" y2="7.25"></line><polyline points="2.75 10.75 2.75 15.25 7.25 15.25"></polyline><line x1="2.75" y1="15.25" x2="7.25" y2="10.75"></line></g></svg>
    Open Fullscreen Modal
  </button>

  <dialog class="modal bg-transparent z-50 m-0 h-full w-full relative max-h-full max-w-full focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <div class="w-screen h-screen bg-white dark:bg-neutral-800 flex flex-col">
      <!-- Fixed header with close button -->
      <div class="fixed right-4 top-4">
        <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
          <span class="sr-only">Close</span>
        </button>
      </div>

      <!-- Scrollable content area -->
      <div class="h-full flex overflow-y-auto p-6 md:justify-center md:items-center">
        <div class="mx-auto max-w-3xl px-4 text-center py-8">
          <h2 class="mb-6 text-4xl font-bold text-neutral-900 dark:text-white">Fullscreen Modal Experience</h2>
          <p class="mb-8 text-lg text-neutral-600 dark:text-neutral-400">
            This modal takes up the entire viewport, providing an immersive experience perfect for media viewers,
            complex workflows, or when you need the user's complete attention.
          </p>

          <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
            <div class="p-6 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
              <div class="mb-3 flex justify-center">
                <svg xmlns="http://www.w3.org/2000/svg" class="size-8 text-neutral-600 dark:text-neutral-300" width="32" height="32" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6V12L16 14"/></svg>
              </div>
              <h3 class="font-semibold text-neutral-900 dark:text-white mb-2">Full Focus</h3>
              <p class="text-sm text-neutral-600 dark:text-neutral-400">Eliminates distractions for better user focus</p>
            </div>

            <div class="p-6 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
              <div class="mb-3 flex justify-center">
                <svg xmlns="http://www.w3.org/2000/svg" class="size-8 text-neutral-600 dark:text-neutral-300" width="32" height="32" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8C3 6.11438 3 5.17157 3.58579 4.58579C4.17157 4 5.11438 4 7 4H17C18.8856 4 19.8284 4 20.4142 4.58579C21 5.17157 21 6.11438 21 8V16C21 17.8856 21 18.8284 20.4142 19.4142C19.8284 20 18.8856 20 17 20H7C5.11438 20 4.17157 20 3.58579 19.4142C3 18.8284 3 17.8856 3 16V8Z"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M7 15L17 15"/></svg>
              </div>
              <h3 class="font-semibold text-neutral-900 dark:text-white mb-2">Maximum Space</h3>
              <p class="text-sm text-neutral-600 dark:text-neutral-400">Utilize entire screen for complex content</p>
            </div>

            <div class="p-6 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
              <div class="mb-3 flex justify-center">
                <svg xmlns="http://www.w3.org/2000/svg" class="size-8 text-neutral-600 dark:text-neutral-300" width="32" height="32" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 2V6M12 18V22M22 12H18M6 12H2M19.0784 19.0784L16.25 16.25M19.0784 4.99994L16.25 7.82837M4.92157 19.0784L7.75 16.25M4.92157 4.99994L7.75 7.82837"/></svg>
              </div>
              <h3 class="font-semibold text-neutral-900 dark:text-white mb-2">Immersive</h3>
              <p class="text-sm text-neutral-600 dark:text-neutral-400">Perfect for media and presentations</p>
            </div>
          </div>

          <div class="flex justify-center gap-3">
            <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
              <svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><polyline points="15.25 7.25 10.75 7.25 10.75 2.75"></polyline><line x1="10.75" y1="7.25" x2="15.25" y2="2.75"></line><polyline points="7.25 15.25 7.25 10.75 2.75 10.75"></polyline><line x1="7.25" y1="10.75" x2="2.75" y2="15.25"></line></g></svg>
              Exit Fullscreen
            </button>
          </div>
        </div>
      </div>
    </div>
  </dialog>
</div>

A modal containing a form for user input.

Report an Issue

Help us improve by reporting any issues you encounter.

<div data-controller="modal">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    Report an Issue
  </button>

  <dialog class="modal max-w-[100vw-2rem] sm:max-w-md bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
        <span class="sr-only">Close</span>
      </button>

      <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Report an Issue</h2>
      <p class="mb-6 text-sm text-neutral-600 dark:text-neutral-400">Help us improve by reporting any issues you encounter.</p>

      <form action="#" method="POST" class="space-y-4" id="issue-form">
        <div>
          <label for="issue-type" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
            Issue Type
          </label>
          <select id="issue-type" name="issue_type" class="form-control">
            <option value="">Select an issue type</option>
            <option value="bug">Bug Report</option>
            <option value="feature">Feature Request</option>
            <option value="improvement">Improvement</option>
            <option value="other">Other</option>
          </select>
        </div>

        <div>
          <label for="title" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
            Title
          </label>
          <input type="text" id="title" name="title" class="form-control" placeholder="Brief description of the issue" required>
        </div>

        <div>
          <label for="description" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
            Description
          </label>
          <textarea id="description" name="description" rows="4" class="form-control" placeholder="Provide details about the issue..." required></textarea>
        </div>

        <div>
          <label for="priority" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
            Priority
          </label>
          <div class="flex gap-4">
            <label class="flex items-center">
              <input type="radio" name="priority" value="low">
              <span class="ml-2 text-sm text-neutral-600 dark:text-neutral-400">Low</span>
            </label>
            <label class="flex items-center">
              <input type="radio" name="priority" value="medium" checked>
              <span class="ml-2 text-sm text-neutral-600 dark:text-neutral-400">Medium</span>
            </label>
            <label class="flex items-center">
              <input type="radio" name="priority" value="high">
              <span class="ml-2 text-sm text-neutral-600 dark:text-neutral-400">High</span>
            </label>
          </div>
        </div>

        <div class="flex items-center">
          <input type="checkbox" id="notify" name="notify">
          <label for="notify" class="ml-2 text-sm text-neutral-600 dark:text-neutral-400">
            Notify me when this issue is resolved
          </label>
        </div>
      </form>

      <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Cancel
        </button>
        <button type="submit" form="issue-form" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Submit Issue
        </button>
      </div>
    </div>
  </dialog>
</div>

A modal that can only be closed via action buttons.

Terms of Service

You must accept our terms of service to continue. This modal cannot be closed without making a choice.

By using our service, you agree to be bound by these terms. These terms constitute a legally binding agreement between you and Rails Blocks.

1. You must be at least 18 years old to use this service.

2. You agree to use the service only for lawful purposes.

3. You are responsible for maintaining the confidentiality of your account.

4. We reserve the right to modify these terms at any time.

<div data-controller="modal">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    Accept Terms
  </button>

  <dialog class="modal max-w-[100vw-2rem] sm:max-w-sm bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <%# No close button %>

      <div class="text-center">
        <div class="mb-4 flex justify-center">
          <div class="p-3 bg-neutral-100 dark:bg-neutral-900/30 rounded-full">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-6 text-neutral-600 dark:text-neutral-400" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" fill="none" stroke="currentColor" class="nc-icon-wrapper" d="M2.75 9.25L6.75 14.25 15.25 3.75"></path></g></svg>
          </div>
        </div>

        <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Terms of Service</h2>
        <p class="mb-6 text-sm text-neutral-600 dark:text-neutral-400">
          You must accept our terms of service to continue. This modal cannot be closed without making a choice.
        </p>

        <div class="mb-6 rounded-lg overflow-hidden bg-neutral-50 border border-black/10 dark:border-white/10 dark:bg-neutral-900">
          <div class="text-left p-4 max-h-40 overflow-y-auto small-scrollbar">
            <p class="text-xs text-neutral-600 dark:text-neutral-400 mb-2">
              By using our service, you agree to be bound by these terms. These terms constitute a legally binding agreement between you and Rails Blocks.
            </p>
            <p class="text-xs text-neutral-600 dark:text-neutral-400 mb-2">
              1. You must be at least 18 years old to use this service.
            </p>
            <p class="text-xs text-neutral-600 dark:text-neutral-400 mb-2">
              2. You agree to use the service only for lawful purposes.
            </p>
            <p class="text-xs text-neutral-600 dark:text-neutral-400 mb-2">
              3. You are responsible for maintaining the confidentiality of your account.
            </p>
            <p class="text-xs text-neutral-600 dark:text-neutral-400">
              4. We reserve the right to modify these terms at any time.
            </p>
          </div>
        </div>
      </div>

      <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Decline
        </button>
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Accept Terms
        </button>
      </div>
    </div>
  </dialog>
</div>

Lazy Loading Modal

A modal that loads its content only when opened, improving performance for heavy content. Uses data-modal-lazy-load-value="true".

Loading content...

<!-- Lazy loading modal example -->
<div data-controller="modal" data-modal-lazy-load-value="true">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    Lazy Loaded Modal
  </button>

  <!-- Template that contains content to be loaded only when modal is opened -->
  <template data-modal-target="template">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
        <span class="sr-only">Close</span>
      </button>

      <h2 class="mb-2 text-lg font-semibold text-neutral-900 dark:text-white">Heavy Content Loaded</h2>

      <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
        This content was loaded only when you opened the modal. This is useful for performance when you have heavy content like:
      </p>

      <ul class="list-disc list-inside mb-4 text-sm text-neutral-600 dark:text-neutral-400 space-y-1">
        <li>Large forms with many fields</li>
        <li>Data tables with lots of rows</li>
        <li>Rich media content like videos or galleries</li>
        <li>Complex components that take time to render</li>
      </ul>

      <div class="mb-4 p-4 bg-neutral-50 dark:bg-neutral-900 rounded-lg border border-black/5 dark:border-white/10 lg:rounded-xl">
        <h3 class="font-medium mb-2 text-neutral-900 dark:text-white">Example: User Settings Form</h3>
        <form class="space-y-4">
          <div>
            <label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Email</label>
            <input type="email" class="w-full form-control" value="[email protected]">
          </div>
          <div>
            <label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Display Name</label>
            <input type="text" class="w-full form-control" value="John Doe">
          </div>
          <div>
            <label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Bio</label>
            <textarea class="w-full form-control min-h-28 max-h-42" rows="3">Software developer passionate about Rails and modern web technologies.</textarea>
          </div>
        </form>
      </div>

      <div class="flex justify-end items-center flex-wrap gap-2">
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#close:prevent">
          Cancel
        </button>
        <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
          Save Changes
        </button>
      </div>
    </div>
  </template>

  <!-- The dialog element that will be populated from the template -->
  <dialog class="modal max-w-[100vw-2rem] sm:max-w-2xl bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200" data-modal-target="dialog" data-action="click->modal#backdropClose">
    <!-- Content placeholder that will be replaced by template contents -->
    <div data-modal-content>
      <div class="p-8 text-center">
        <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p class="text-neutral-600 dark:text-neutral-400">Loading content...</p>
      </div>
    </div>
  </dialog>
</div>

Turbo Modal

A modal that loads content from the server using Turbo Frames. Uses data-modal-lazy-load-value="true" & data-modal-turbo-frame-src-value="<%= modal_content_path %>".

Loading from server...

<div data-controller="modal" data-modal-turbo-frame-src-value="<%= modal_content_path %>" data-modal-lazy-load-value="true">
  <button class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-action="click->modal#open:prevent">
    User Profile
  </button>

  <!-- Dialog to be populated via Turbo Frame -->
  <dialog
    class="modal max-w-[100vw-2rem] sm:max-w-lg bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200"
    data-modal-target="dialog"
    data-action="click->modal#backdropClose"
  >
    <div data-modal-content>
      <div class="p-8 text-center">
        <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
        <p class="text-neutral-600 dark:text-neutral-400">Loading from server...</p>
      </div>
    </div>
  </dialog>
</div>
# Modal content route for Turbo Frame lazy loading
get "/modal_content", to: "pages#modal_content", as: "modal_content"
# Modal content action for Turbo Frame lazy loading
def modal_content
  render partial: "components/modal/modal_content", layout: false
end

Configuration

The modal component is powered by a Stimulus controller that provides smooth animations, backdrop click handling, and Turbo integration.

HTML Structure

Basic modal structure with required data attributes:

<div data-controller="modal">
  <button data-action="click->modal#open:prevent">Open Modal</button>

  <dialog data-modal-target="dialog" data-action="click->modal#backdropClose" class="modal bg-transparent rounded-lg w-full sm:rounded-xl lg:rounded-2xl z-50 max-w-md border border-neutral-950/10 dark:border-white/10 bg-white dark:bg-neutral-800 small-scrollbar focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
    <div class="sm:max-w-3xl row-start-2 w-full rounded-xl lg:rounded-2xl bg-white p-6 dark:bg-neutral-800 forced-colors:outline">
      <button type="button" data-action="modal#close:prevent" class="z-10 p-1.5 absolute right-4 top-5 rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none hover:bg-neutral-500/15 active:bg-neutral-500/25 disabled:pointer-events-none">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><line x1="18" x2="6" y1="6" y2="18"></line><line x1="6" x2="18" y1="6" y2="18"></line></svg>
        <span class="sr-only">Close</span>
      </button>

      <!-- Content -->

      <div class="flex justify-end items-center flex-wrap gap-2 mt-4">
        <!-- Action buttons -->
      </div>
    </div>
  </dialog>
</div>

Lazy Loading

Enable lazy loading to improve performance by loading content only when the modal is opened:

Template-based Lazy Loading

<div data-controller="modal"
     data-modal-lazy-load-value="true">
  <button data-action="click->modal#open:prevent">Open Modal</button>

  <!-- Template with content to load -->
  <template data-modal-target="template">
    <div class="p-6">
      <!-- Your heavy content here -->
    </div>
  </template>

  <dialog data-modal-target="dialog">
    <div data-modal-content>
      <!-- Loading placeholder -->
      <div class="p-8 text-center">Loading...</div>
    </div>
  </dialog>
</div>

Turbo Frame Lazy Loading

<div data-controller="modal"
     data-modal-lazy-load-value="true"
     data-modal-turbo-frame-src-value="/modal/content">
  <button data-action="click->modal#open:prevent">Open Modal</button>

  <dialog data-modal-target="dialog">
    <div data-modal-content>
      <!-- Turbo frame will be created automatically -->
      <div class="p-8 text-center">Loading from server...</div>
    </div>
  </dialog>
</div>

Configuration Values

Prop Description Type Default
open
Controls whether the modal is open on initial load Boolean false
lazyLoad
Whether to load modal content only when opened Boolean false
turboFrameSrc
URL for Turbo Frame lazy loading String ""
preventDismiss
Whether to prevent the modal from being dismissed Boolean false

Targets

Target Description Required
dialog
The dialog element that contains the modal content Required
template
Template element for lazy loading content Optional

Actions

Action Description Usage
open
Opens the modal as a modal dialog data-action="click->modal#open:prevent"
close
Closes the modal with animations data-action="click->modal#close:prevent"
backdropClose
Closes the modal when clicking outside the content data-action="click->modal#backdropClose"
show
Shows the dialog non-modally (for special use cases) data-action="click->modal#show"
hide
Alias for close method data-action="click->modal#hide"

Different modal sizes can be achieved by changing the max-w-* class on the dialog element:

Size Class Description
Small max-w-sm Perfect for brief confirmations or simple messages
Medium (Default) max-w-md Works well for most content types
Large max-w-lg More space for complex forms or detailed content
Extra Large max-w-xl Substantial space for data tables or multi-step forms
2XL max-w-2xl Even more space for extensive content
3XL max-w-3xl Maximum width while maintaining margins
Fullscreen max-w-full max-h-full Takes up the entire viewport

Key Features

  • Native Dialog Element: Uses the HTML dialog element for better accessibility and browser support
  • Smooth Animations: Scale and fade animations with closing state handling
  • Backdrop Click: Closes when clicking outside the modal content
  • Turbo Integration: Automatically closes before Turbo caches the page
  • Body Scroll Lock: Prevents body scrolling when modal is open
  • Multiple Sizes: From small to fullscreen, with responsive design
  • Lazy Loading: Load content only when modal is opened for better performance
  • Turbo Frame Support: Load modal content from server using Turbo Frames

Accessibility Features

  • Focus Management: Uses native dialog focus trapping
  • Screen Reader Support: Proper ARIA attributes with close button labels
  • Keyboard Navigation: ESC key closes the modal (native dialog behavior)
  • Semantic Markup: Uses the native dialog element for proper semantics

Usage Notes

  • The dialog element must have the modal class for proper styling
  • Use :prevent modifier on actions to prevent default link/button behavior
  • The controller handles animation cleanup to ensure smooth closing transitions
  • For Rails forms with Turbo, consider using data-turbo-frame="_top" on the form
  • To hide the close button, simply omit it from the HTML structure

Table of contents