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
- Wrap both the file list and the modal in one
data-controller="file-preview"element. - 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. - Put a single
<dialog>on the page containing an<iframe>(for PDFs) and an<img>(for images), both hidden by default. - The controller reads the row's data attributes, sets
iframe.src, unhides the right one, and callsdialog.showModal(). - On close, clear
iframe.srcso 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 toattachment, which sendsContent-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:inlinefor the iframe,attachmentfor the download button.min-h-0on the flex body, or the iframe overflows the modal. A flex child's defaultmin-height: autorefuses to shrink below its content, soflex-1alone lets the iframe blow pasth-[90vh]and the modal grows a second scrollbar.flex-1 min-h-0on the wrapper +h-fullon the iframe is the working combination.- Esc bypasses your close handler.
<dialog>closes on Escape natively — yourclose()action never runs, sooverflow-hiddenstays on<body>(page looks frozen) and the iframe keeps holding the old document. Listen for the dialog's owncloseevent and do cleanup there, then make your close button just call.close(). This is the single most common bug in this pattern. - Clear
iframe.srcon 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 resolvesthis.frameTargetto the first one, so every row previews the same file. Render one, swap itssrc. - The
data-controllerwrapper 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 andopen()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_listafter an upload or delete re-inserts rows with thedata-actionattributes; 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_typereturnsniland 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 withservice_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_pathURLs are signed but guessable-forever if leaked. If documents are sensitive, proxy them through your own controller that checkscurrent_userbeforesend_data/redirect_to, and pointdata-preview-urlat 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
- Rename the model.
CompanyFileis just "a record thathas_one_attached :file". Swap inReceipt,Invoice,Attachment, or usehas_many_attachedand iteraterecord.files.each— the row markup is per attachment, not per record. - 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. - Add types by extending
preview_typeonly. Want to preview plain text or video? Return"text"/"video", add a matching target element to the dialog, and add oneelse ifbranch inopen(). The row markup never changes. - Not on DaisyUI? The only DaisyUI pieces are the
modal/modal-box/modal-backdropclasses. The behavior comes from the native<dialog>+showModal(), which works with any CSS — styledialoganddialog::backdropyourself and keep everything else. - Not on Stimulus? The controller is ~40 lines of plain DOM code. Bind a delegated
clicklistener on the list container and read the samedatasetattributes.