---
title: PDF & Image Preview Modal for Uploaded Files
slug: pdf-preview-modal
category: Files
summary: Click an uploaded document row to preview the PDF (or image) full-screen in a native <dialog> modal, with a download button — no page reload, no PDF library, no extra gems.
tags: [stimulus, active-storage, pdf, modal, dialog, files, daisyui, ux-default]
status: stable
visibility: public
source_project: lp-finances.leo.llamapress.ai (company Documents section)
layers: [model, view, stimulus_js]
related:
  - title: Generating PDFs from HTML
    url: /cookbook/pdf-generation
    summary: The other half of the story — how to CREATE the PDF you're previewing here.
  - title: UX Principles Guide for Building Web Software
    url: /wiki/ux-principles-for-web-software
    summary: Why previewing in place beats downloading — keep the user in one context (Principle 2).
---

# PDF & Image Preview Modal for Uploaded Files

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

A user uploads receipts, invoices, and statements. They want to *look* at one — not
download it, open their file manager, find it, open Preview, and lose their place. This
pattern makes any previewable file row clickable: click it and the PDF opens in a large
modal over the page, with the file name in the header and a download button next to it.
Close it and you're exactly where you were.

The whole thing is **one Stimulus controller and one `<dialog>` element**. There is no PDF
library, no viewer gem, and no server-side rendering — the browser's built-in PDF viewer
does the work inside an `<iframe>`. Images use the same modal with an `<img>` instead.

> **When to use:** any list of user-uploaded documents (receipts, contracts, statements,
> ID scans) where the file is small-to-medium and users mostly *read* it.
> **When not to:** files the browser can't render (Word, Excel, ZIP) — leave those as
> plain download links; a modal that shows nothing is worse than no modal. Also skip it
> for very large PDFs (50MB+) where the in-browser viewer stalls.

---

## The 80/20 in one breath

1. Wrap **both** the file list **and** the modal in one `data-controller="file-preview"`
   element.
2. On each previewable row, add `data-action="click->file-preview#open"` plus four data
   attributes: the **inline** blob URL, the type (`pdf`/`image`), the file name, and the
   **attachment** blob URL for the download button.
3. Put a single `<dialog>` on the page containing an `<iframe>` (for PDFs) and an `<img>`
   (for images), both hidden by default.
4. The controller reads the row's data attributes, sets `iframe.src`, unhides the right
   one, and calls `dialog.showModal()`.
5. On close, clear `iframe.src` so the next open doesn't flash the previous document.

The one line that makes or breaks it: **`disposition: "inline"`**. Active Storage defaults
to `attachment`, which tells the browser to download instead of render.

---

## Layer 1 — Model (Active Storage attachment)

Nothing exotic. One attachment plus a content-type allowlist, so you know at render time
whether a file is previewable at all.

```ruby
# app/models/company_file.rb
class CompanyFile < ApplicationRecord
  belongs_to :company
  belongs_to :user

  has_one_attached :file

  validates :name, presence: { message: "Please give this file a name" }
  validates :file, presence: { message: "Please select a file to upload" }
  validate  :file_content_type, if: -> { file.attached? }
  validate  :file_size,         if: -> { file.attached? }

  # Only these ever reach the preview modal. Word/Excel/CSV are allowed as
  # uploads but are NOT previewable — see the helper in Layer 2.
  ALLOWED_CONTENT_TYPES = %w[
    application/pdf
    application/msword
    application/vnd.openxmlformats-officedocument.wordprocessingml.document
    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
    application/vnd.ms-excel
    text/csv
    image/jpeg image/png image/gif image/webp image/svg+xml
  ].freeze

  MAX_FILE_SIZE = 50.megabytes

  private

  def file_content_type
    unless file.blob.content_type.in?(ALLOWED_CONTENT_TYPES)
      errors.add(:file, "must be a PDF, Word doc, Excel file, CSV, or image")
    end
  end

  def file_size
    if file.blob.byte_size > MAX_FILE_SIZE
      errors.add(:file, "must be less than 50MB")
    end
  end
end
```

## Layer 2 — Helper (what is previewable, and its icon)

Two tiny helpers keep the view clean. `preview_type` returns `"pdf"`, `"image"`, or
**`nil`** — and `nil` is the signal to render the row as *not clickable*.

```ruby
# app/helpers/application_helper.rb
module ApplicationHelper
  # nil means "the browser can't show this" -> don't wire up the modal.
  def preview_type(content_type)
    case content_type
    when "application/pdf" then "pdf"
    when /image/           then "image"
    else nil
    end
  end

  def file_type_icon(content_type)
    case content_type
    when "application/pdf"        then '<i class="fas fa-file-pdf text-red-500"></i>'
    when /word/                   then '<i class="fas fa-file-word text-blue-500"></i>'
    when /excel/, /spreadsheet/   then '<i class="fas fa-file-excel text-green-600"></i>'
    when "text/csv", "application/csv" then '<i class="fas fa-file-csv text-cyan-600"></i>'
    when /image/                  then '<i class="fas fa-file-image text-purple-500"></i>'
    else '<i class="fas fa-file text-base-content/50"></i>'
    end.html_safe
  end
end
```

## Layer 3a — The View: the file row

Each row carries everything the modal needs. Note the two *different* blob paths:
`inline` for the preview, `attachment` for the download button.

```erb
<%# app/views/company_files/_document_list.html.erb %>
<div class="divide-y divide-slate-100">
  <% company_files.each do |cf| %>
    <% ptype        = preview_type(cf.file.content_type) %>
    <% preview_url  = ptype ? rails_blob_path(cf.file, disposition: "inline") : nil %>
    <% download_url = rails_blob_path(cf.file, disposition: "attachment") %>

    <div class="flex items-center gap-3 px-4 py-3 hover:bg-base-200 transition-colors">
      <%# Clickable ONLY when previewable — a nil ptype leaves a plain, inert row. %>
      <div class="flex items-center gap-3 flex-1 min-w-0 <%= ptype ? 'cursor-pointer' : '' %>"
           <% if ptype %>
             data-action="click->file-preview#open"
             data-preview-url="<%= preview_url %>"
             data-preview-type="<%= ptype %>"
             data-file-name="<%= cf.name %>"
             data-download-url="<%= download_url %>"
           <% end %>>
        <div class="text-xl w-8 text-center shrink-0"><%= file_type_icon(cf.file.content_type) %></div>
        <div class="flex-1 min-w-0">
          <p class="font-medium text-sm truncate"><%= cf.name %></p>
          <p class="text-xs text-base-content/50"><%= number_to_human_size(cf.file.byte_size) %></p>
        </div>
      </div>

      <%# Actions stay OUTSIDE the clickable area so download/delete don't open the modal. %>
      <div class="flex items-center gap-1 shrink-0">
        <%= link_to download_url, class: "btn btn-ghost btn-xs btn-square", title: "Download" do %>
          <i class="fas fa-download"></i>
        <% end %>
        <%= button_to company_company_file_path(company, cf), method: :delete,
              class: "btn btn-ghost btn-xs btn-square text-red-500",
              data: { turbo_confirm: "Delete '#{cf.name}'?" }, title: "Delete" do %>
          <i class="fas fa-trash"></i>
        <% end %>
      </div>
    </div>
  <% end %>
</div>
```

## Layer 3b — The View: the modal (one per page, not one per row)

The `data-controller` wrapper must contain **both** the list and the `<dialog>` — that's
the Stimulus scope. Render exactly one dialog and reuse it for every file.

```erb
<%# app/views/companies/show.html.erb %>
<div data-controller="file-preview">

  <div class="card bg-base-100 border border-slate-200 shadow-sm">
    <div class="card-body p-6">
      <h2 class="text-xl font-bold flex items-center gap-2 mb-4">
        <i class="fas fa-folder text-amber-500"></i> Documents
      </h2>
      <%# Turbo Stream replaces THIS div after upload/delete — the dialog below survives. %>
      <div id="document_list">
        <%= render "company_files/document_list", company_files: @company_files, company: @company %>
      </div>
    </div>
  </div>

  <%# ---- File preview modal ---- %>
  <dialog data-file-preview-target="modal" class="modal">
    <%# h-[90vh] + flex-col: the header is fixed height, the body takes the rest. %>
    <div class="modal-box w-11/12 max-w-5xl h-[90vh] p-0 rounded-xl flex flex-col">
      <div class="flex items-center justify-between px-6 py-3 border-b border-slate-200 shrink-0">
        <h3 class="font-semibold text-lg truncate flex items-center gap-2">
          <i class="fas fa-file text-indigo-500"></i>
          <span data-file-preview-target="fileName">Preview</span>
        </h3>
        <div class="flex items-center gap-1">
          <a data-file-preview-target="downloadLink" class="btn btn-ghost btn-sm btn-square"
             title="Download" download>
            <i class="fas fa-download"></i>
          </a>
          <button data-action="click->file-preview#close" class="btn btn-ghost btn-sm btn-square">
            <i class="fas fa-times text-lg"></i>
          </button>
        </div>
      </div>

      <%# min-h-0 is REQUIRED — without it this flex child refuses to shrink and the
          iframe pushes past the modal. See Gotchas. %>
      <div class="flex-1 bg-slate-100 min-h-0">
        <iframe data-file-preview-target="frame" class="w-full h-full hidden" frameborder="0"></iframe>
        <div data-file-preview-target="imageWrapper"
             class="w-full h-full hidden items-center justify-center p-4">
          <img data-file-preview-target="image" src=""
               class="max-w-full max-h-full object-contain rounded shadow-sm">
        </div>
      </div>
    </div>

    <%# DaisyUI backdrop: clicking outside submits this form, which closes the dialog. %>
    <form method="dialog" class="modal-backdrop">
      <button data-action="click->file-preview#close">close</button>
    </form>
  </dialog>
</div>
```

## Layer 4 — Stimulus controller

```javascript
// app/javascript/controllers/file_preview_controller.js
import { Controller } from "@hotwired/stimulus"

// Previews PDFs and images from a file list in a native <dialog> modal.
// Rows opt in with data-preview-url / data-preview-type / data-file-name /
// data-download-url and data-action="click->file-preview#open".
export default class extends Controller {
  static targets = ["modal", "frame", "image", "imageWrapper", "fileName", "downloadLink"]

  connect() {
    // Clicking the dialog element itself (not the modal-box) = backdrop click.
    this.modalTarget.addEventListener("click", (event) => {
      if (event.target === this.modalTarget) this.close()
    })

    // Esc closes a <dialog> natively WITHOUT calling our close() — listen for the
    // browser's own "close" event so cleanup always runs. (See Gotchas.)
    this.modalTarget.addEventListener("close", () => this.cleanup())
  }

  open(event) {
    const row = event.currentTarget
    const url = row.dataset.previewUrl
    if (!url) return

    const type = row.dataset.previewType
    this.fileNameTarget.textContent = row.dataset.fileName || "Preview"
    this.downloadLinkTarget.href = row.dataset.downloadUrl || url

    if (type === "pdf") {
      this.frameTarget.src = url
      this.frameTarget.classList.remove("hidden")
      this.imageWrapperTarget.classList.add("hidden")
    } else if (type === "image") {
      this.imageTarget.src = url
      this.imageWrapperTarget.classList.remove("hidden")
      this.imageWrapperTarget.classList.add("flex")
      this.frameTarget.classList.add("hidden")
    }

    this.modalTarget.showModal()          // native: focus trap + Esc + ::backdrop
    document.body.classList.add("overflow-hidden")   // stop the page behind scrolling
  }

  close() {
    this.modalTarget.close()   // fires the "close" event -> cleanup()
  }

  cleanup() {
    document.body.classList.remove("overflow-hidden")
    // Blank the sources AFTER the close animation, so the old document doesn't
    // visibly vanish mid-fade — and so it isn't still rendering in memory.
    setTimeout(() => {
      this.frameTarget.src = ""
      this.imageTarget.src = ""
    }, 300)
  }

  disconnect() {
    document.body.classList.remove("overflow-hidden")
  }
}
```

---

## Gotchas (the hard-won stuff)

- **`disposition: "inline"` is the whole feature.** `rails_blob_path(file)` defaults to
  `attachment`, which sends `Content-Disposition: attachment` — the browser downloads the
  PDF instead of rendering it, and your iframe stays blank while a file lands in
  `~/Downloads`. Build **two** URLs per row: `inline` for the iframe, `attachment` for the
  download button.
- **`min-h-0` on the flex body, or the iframe overflows the modal.** A flex child's default
  `min-height: auto` refuses to shrink below its content, so `flex-1` alone lets the iframe
  blow past `h-[90vh]` and the modal grows a second scrollbar. `flex-1 min-h-0` on the
  wrapper + `h-full` on the iframe is the working combination.
- **Esc bypasses your close handler.** `<dialog>` closes on Escape natively — your
  `close()` action never runs, so `overflow-hidden` stays on `<body>` (page looks frozen)
  and the iframe keeps holding the old document. Listen for the dialog's own **`close`
  event** and do cleanup there, then make your close button just call `.close()`. This is
  the single most common bug in this pattern.
- **Clear `iframe.src` on close — on a timer.** Clear it immediately and the viewer goes
  white *during* the fade-out. Clear it never, and the next open flashes the previous
  document for a beat before the new one paints. `setTimeout(..., 300)` matches the modal
  animation.
- **One dialog for the whole list.** Rendering a `<dialog>` per row means N iframes on the
  page and N sets of duplicate Stimulus targets — Stimulus resolves `this.frameTarget` to
  the *first* one, so every row previews the same file. Render one, swap its `src`.
- **The `data-controller` wrapper must enclose the dialog too.** If the `<dialog>` sits
  outside the wrapper (a common mistake when the modal is moved near `</body>`), the
  targets are out of scope and `open()` throws "Missing target element".
- **Keep row action buttons outside the clickable area.** If the download/delete buttons
  are inside the element carrying `click->file-preview#open`, clicking Delete also opens
  the modal. Nest the clickable region as its own div, siblings for the actions.
- **Turbo Stream re-renders are fine.** Replacing `#document_list` after an upload or
  delete re-inserts rows with the `data-action` attributes; Stimulus re-binds them
  automatically because it watches the DOM. Just never put the dialog inside the replaced
  region — it would be destroyed while open.
- **Not every browser renders PDFs in an iframe.** Mobile Safari and some Android browsers
  ignore it or show a grey box. Always keep the download button in the modal header — it's
  the fallback, not a nicety. Optionally render a "having trouble? open in a new tab" link.
- **Non-previewable types must not be clickable.** Word/Excel/CSV in an iframe give a blank
  panel or trigger a download behind the modal. That's why `preview_type` returns `nil` and
  the view omits the data attributes entirely.
- **Content Security Policy can block the iframe.** If your app sets a CSP with a
  restrictive `frame-src`, add `'self'` (and your S3/CDN host if Active Storage redirects
  there with `service_urls_expire_in`). Symptom: empty iframe plus a CSP violation in the
  console.
- **Font Awesome isn't guaranteed on every box.** The icons here are `fas fa-*`. If FA
  isn't loaded, swap the `<i>` tags for inline SVGs — nothing in the logic depends on them.
- **Private files still need authorization.** `rails_blob_path` URLs are signed but
  guessable-forever if leaked. If documents are sensitive, proxy them through your own
  controller that checks `current_user` before `send_data`/`redirect_to`, and point
  `data-preview-url` at that instead.

---

## Files this pattern touches

```
app/models/company_file.rb                              # has_one_attached + content-type allowlist
app/helpers/application_helper.rb                       # preview_type + file_type_icon
app/views/company_files/_document_list.html.erb         # the clickable rows + data attributes
app/views/companies/show.html.erb                       # the data-controller wrapper + <dialog>
app/javascript/controllers/file_preview_controller.js   # open / close / cleanup
```

## How to adapt to your schema

1. **Rename the model.** `CompanyFile` is just "a record that `has_one_attached :file`".
   Swap in `Receipt`, `Invoice`, `Attachment`, or use `has_many_attached` and iterate
   `record.files.each` — the row markup is per *attachment*, not per record.
2. **Drop the parent scoping.** The example nests files under a company
   (`current_user.companies.find(params[:company_id])`). If your files hang off the user
   directly, delete the company plumbing; nothing in the modal cares.
3. **Add types by extending `preview_type` only.** Want to preview plain text or video?
   Return `"text"` / `"video"`, add a matching target element to the dialog, and add one
   `else if` branch in `open()`. The row markup never changes.
4. **Not on DaisyUI?** The only DaisyUI pieces are the `modal` / `modal-box` /
   `modal-backdrop` classes. The behavior comes from the native `<dialog>` +
   `showModal()`, which works with any CSS — style `dialog` and `dialog::backdrop`
   yourself and keep everything else.
5. **Not on Stimulus?** The controller is ~40 lines of plain DOM code. Bind a delegated
   `click` listener on the list container and read the same `dataset` attributes.
