PDF & Image Preview Modal for Uploaded Files | LlamaPress Cookbook
LlamaPress
Cookbook
Files Stable

PDF & Image Preview Modal for Uploaded Files

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.

Proven on lp-finances.leo.llamapress.ai (company Documents section) model · view · stimulus_js
Live demo — try it Open full screen

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.

# 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.

# 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.

<%# 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.

<%# 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

// 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.

Related