{"slug":"pdf-preview-modal","meta":{"title":"PDF \u0026 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 \u003cdialog\u003e 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)."}]},"body":"# PDF \u0026 Image Preview Modal for Uploaded Files\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nA user uploads receipts, invoices, and statements. They want to *look* at one — not\ndownload it, open their file manager, find it, open Preview, and lose their place. This\npattern makes any previewable file row clickable: click it and the PDF opens in a large\nmodal over the page, with the file name in the header and a download button next to it.\nClose it and you're exactly where you were.\n\nThe whole thing is **one Stimulus controller and one `\u003cdialog\u003e` element**. There is no PDF\nlibrary, no viewer gem, and no server-side rendering — the browser's built-in PDF viewer\ndoes the work inside an `\u003ciframe\u003e`. Images use the same modal with an `\u003cimg\u003e` instead.\n\n\u003e **When to use:** any list of user-uploaded documents (receipts, contracts, statements,\n\u003e ID scans) where the file is small-to-medium and users mostly *read* it.\n\u003e **When not to:** files the browser can't render (Word, Excel, ZIP) — leave those as\n\u003e plain download links; a modal that shows nothing is worse than no modal. Also skip it\n\u003e for very large PDFs (50MB+) where the in-browser viewer stalls.\n\n---\n\n## The 80/20 in one breath\n\n1. Wrap **both** the file list **and** the modal in one `data-controller=\"file-preview\"`\n   element.\n2. On each previewable row, add `data-action=\"click-\u003efile-preview#open\"` plus four data\n   attributes: the **inline** blob URL, the type (`pdf`/`image`), the file name, and the\n   **attachment** blob URL for the download button.\n3. Put a single `\u003cdialog\u003e` on the page containing an `\u003ciframe\u003e` (for PDFs) and an `\u003cimg\u003e`\n   (for images), both hidden by default.\n4. The controller reads the row's data attributes, sets `iframe.src`, unhides the right\n   one, and calls `dialog.showModal()`.\n5. On close, clear `iframe.src` so the next open doesn't flash the previous document.\n\nThe one line that makes or breaks it: **`disposition: \"inline\"`**. Active Storage defaults\nto `attachment`, which tells the browser to download instead of render.\n\n---\n\n## Layer 1 — Model (Active Storage attachment)\n\nNothing exotic. One attachment plus a content-type allowlist, so you know at render time\nwhether a file is previewable at all.\n\n```ruby\n# app/models/company_file.rb\nclass CompanyFile \u003c ApplicationRecord\n  belongs_to :company\n  belongs_to :user\n\n  has_one_attached :file\n\n  validates :name, presence: { message: \"Please give this file a name\" }\n  validates :file, presence: { message: \"Please select a file to upload\" }\n  validate  :file_content_type, if: -\u003e { file.attached? }\n  validate  :file_size,         if: -\u003e { file.attached? }\n\n  # Only these ever reach the preview modal. Word/Excel/CSV are allowed as\n  # uploads but are NOT previewable — see the helper in Layer 2.\n  ALLOWED_CONTENT_TYPES = %w[\n    application/pdf\n    application/msword\n    application/vnd.openxmlformats-officedocument.wordprocessingml.document\n    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n    application/vnd.ms-excel\n    text/csv\n    image/jpeg image/png image/gif image/webp image/svg+xml\n  ].freeze\n\n  MAX_FILE_SIZE = 50.megabytes\n\n  private\n\n  def file_content_type\n    unless file.blob.content_type.in?(ALLOWED_CONTENT_TYPES)\n      errors.add(:file, \"must be a PDF, Word doc, Excel file, CSV, or image\")\n    end\n  end\n\n  def file_size\n    if file.blob.byte_size \u003e MAX_FILE_SIZE\n      errors.add(:file, \"must be less than 50MB\")\n    end\n  end\nend\n```\n\n## Layer 2 — Helper (what is previewable, and its icon)\n\nTwo tiny helpers keep the view clean. `preview_type` returns `\"pdf\"`, `\"image\"`, or\n**`nil`** — and `nil` is the signal to render the row as *not clickable*.\n\n```ruby\n# app/helpers/application_helper.rb\nmodule ApplicationHelper\n  # nil means \"the browser can't show this\" -\u003e don't wire up the modal.\n  def preview_type(content_type)\n    case content_type\n    when \"application/pdf\" then \"pdf\"\n    when /image/           then \"image\"\n    else nil\n    end\n  end\n\n  def file_type_icon(content_type)\n    case content_type\n    when \"application/pdf\"        then '\u003ci class=\"fas fa-file-pdf text-red-500\"\u003e\u003c/i\u003e'\n    when /word/                   then '\u003ci class=\"fas fa-file-word text-blue-500\"\u003e\u003c/i\u003e'\n    when /excel/, /spreadsheet/   then '\u003ci class=\"fas fa-file-excel text-green-600\"\u003e\u003c/i\u003e'\n    when \"text/csv\", \"application/csv\" then '\u003ci class=\"fas fa-file-csv text-cyan-600\"\u003e\u003c/i\u003e'\n    when /image/                  then '\u003ci class=\"fas fa-file-image text-purple-500\"\u003e\u003c/i\u003e'\n    else '\u003ci class=\"fas fa-file text-base-content/50\"\u003e\u003c/i\u003e'\n    end.html_safe\n  end\nend\n```\n\n## Layer 3a — The View: the file row\n\nEach row carries everything the modal needs. Note the two *different* blob paths:\n`inline` for the preview, `attachment` for the download button.\n\n```erb\n\u003c%# app/views/company_files/_document_list.html.erb %\u003e\n\u003cdiv class=\"divide-y divide-slate-100\"\u003e\n  \u003c% company_files.each do |cf| %\u003e\n    \u003c% ptype        = preview_type(cf.file.content_type) %\u003e\n    \u003c% preview_url  = ptype ? rails_blob_path(cf.file, disposition: \"inline\") : nil %\u003e\n    \u003c% download_url = rails_blob_path(cf.file, disposition: \"attachment\") %\u003e\n\n    \u003cdiv class=\"flex items-center gap-3 px-4 py-3 hover:bg-base-200 transition-colors\"\u003e\n      \u003c%# Clickable ONLY when previewable — a nil ptype leaves a plain, inert row. %\u003e\n      \u003cdiv class=\"flex items-center gap-3 flex-1 min-w-0 \u003c%= ptype ? 'cursor-pointer' : '' %\u003e\"\n           \u003c% if ptype %\u003e\n             data-action=\"click-\u003efile-preview#open\"\n             data-preview-url=\"\u003c%= preview_url %\u003e\"\n             data-preview-type=\"\u003c%= ptype %\u003e\"\n             data-file-name=\"\u003c%= cf.name %\u003e\"\n             data-download-url=\"\u003c%= download_url %\u003e\"\n           \u003c% end %\u003e\u003e\n        \u003cdiv class=\"text-xl w-8 text-center shrink-0\"\u003e\u003c%= file_type_icon(cf.file.content_type) %\u003e\u003c/div\u003e\n        \u003cdiv class=\"flex-1 min-w-0\"\u003e\n          \u003cp class=\"font-medium text-sm truncate\"\u003e\u003c%= cf.name %\u003e\u003c/p\u003e\n          \u003cp class=\"text-xs text-base-content/50\"\u003e\u003c%= number_to_human_size(cf.file.byte_size) %\u003e\u003c/p\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n\n      \u003c%# Actions stay OUTSIDE the clickable area so download/delete don't open the modal. %\u003e\n      \u003cdiv class=\"flex items-center gap-1 shrink-0\"\u003e\n        \u003c%= link_to download_url, class: \"btn btn-ghost btn-xs btn-square\", title: \"Download\" do %\u003e\n          \u003ci class=\"fas fa-download\"\u003e\u003c/i\u003e\n        \u003c% end %\u003e\n        \u003c%= button_to company_company_file_path(company, cf), method: :delete,\n              class: \"btn btn-ghost btn-xs btn-square text-red-500\",\n              data: { turbo_confirm: \"Delete '#{cf.name}'?\" }, title: \"Delete\" do %\u003e\n          \u003ci class=\"fas fa-trash\"\u003e\u003c/i\u003e\n        \u003c% end %\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\n## Layer 3b — The View: the modal (one per page, not one per row)\n\nThe `data-controller` wrapper must contain **both** the list and the `\u003cdialog\u003e` — that's\nthe Stimulus scope. Render exactly one dialog and reuse it for every file.\n\n```erb\n\u003c%# app/views/companies/show.html.erb %\u003e\n\u003cdiv data-controller=\"file-preview\"\u003e\n\n  \u003cdiv class=\"card bg-base-100 border border-slate-200 shadow-sm\"\u003e\n    \u003cdiv class=\"card-body p-6\"\u003e\n      \u003ch2 class=\"text-xl font-bold flex items-center gap-2 mb-4\"\u003e\n        \u003ci class=\"fas fa-folder text-amber-500\"\u003e\u003c/i\u003e Documents\n      \u003c/h2\u003e\n      \u003c%# Turbo Stream replaces THIS div after upload/delete — the dialog below survives. %\u003e\n      \u003cdiv id=\"document_list\"\u003e\n        \u003c%= render \"company_files/document_list\", company_files: @company_files, company: @company %\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003c%# ---- File preview modal ---- %\u003e\n  \u003cdialog data-file-preview-target=\"modal\" class=\"modal\"\u003e\n    \u003c%# h-[90vh] + flex-col: the header is fixed height, the body takes the rest. %\u003e\n    \u003cdiv class=\"modal-box w-11/12 max-w-5xl h-[90vh] p-0 rounded-xl flex flex-col\"\u003e\n      \u003cdiv class=\"flex items-center justify-between px-6 py-3 border-b border-slate-200 shrink-0\"\u003e\n        \u003ch3 class=\"font-semibold text-lg truncate flex items-center gap-2\"\u003e\n          \u003ci class=\"fas fa-file text-indigo-500\"\u003e\u003c/i\u003e\n          \u003cspan data-file-preview-target=\"fileName\"\u003ePreview\u003c/span\u003e\n        \u003c/h3\u003e\n        \u003cdiv class=\"flex items-center gap-1\"\u003e\n          \u003ca data-file-preview-target=\"downloadLink\" class=\"btn btn-ghost btn-sm btn-square\"\n             title=\"Download\" download\u003e\n            \u003ci class=\"fas fa-download\"\u003e\u003c/i\u003e\n          \u003c/a\u003e\n          \u003cbutton data-action=\"click-\u003efile-preview#close\" class=\"btn btn-ghost btn-sm btn-square\"\u003e\n            \u003ci class=\"fas fa-times text-lg\"\u003e\u003c/i\u003e\n          \u003c/button\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n\n      \u003c%# min-h-0 is REQUIRED — without it this flex child refuses to shrink and the\n          iframe pushes past the modal. See Gotchas. %\u003e\n      \u003cdiv class=\"flex-1 bg-slate-100 min-h-0\"\u003e\n        \u003ciframe data-file-preview-target=\"frame\" class=\"w-full h-full hidden\" frameborder=\"0\"\u003e\u003c/iframe\u003e\n        \u003cdiv data-file-preview-target=\"imageWrapper\"\n             class=\"w-full h-full hidden items-center justify-center p-4\"\u003e\n          \u003cimg data-file-preview-target=\"image\" src=\"\"\n               class=\"max-w-full max-h-full object-contain rounded shadow-sm\"\u003e\n        \u003c/div\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n\n    \u003c%# DaisyUI backdrop: clicking outside submits this form, which closes the dialog. %\u003e\n    \u003cform method=\"dialog\" class=\"modal-backdrop\"\u003e\n      \u003cbutton data-action=\"click-\u003efile-preview#close\"\u003eclose\u003c/button\u003e\n    \u003c/form\u003e\n  \u003c/dialog\u003e\n\u003c/div\u003e\n```\n\n## Layer 4 — Stimulus controller\n\n```javascript\n// app/javascript/controllers/file_preview_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\n// Previews PDFs and images from a file list in a native \u003cdialog\u003e modal.\n// Rows opt in with data-preview-url / data-preview-type / data-file-name /\n// data-download-url and data-action=\"click-\u003efile-preview#open\".\nexport default class extends Controller {\n  static targets = [\"modal\", \"frame\", \"image\", \"imageWrapper\", \"fileName\", \"downloadLink\"]\n\n  connect() {\n    // Clicking the dialog element itself (not the modal-box) = backdrop click.\n    this.modalTarget.addEventListener(\"click\", (event) =\u003e {\n      if (event.target === this.modalTarget) this.close()\n    })\n\n    // Esc closes a \u003cdialog\u003e natively WITHOUT calling our close() — listen for the\n    // browser's own \"close\" event so cleanup always runs. (See Gotchas.)\n    this.modalTarget.addEventListener(\"close\", () =\u003e this.cleanup())\n  }\n\n  open(event) {\n    const row = event.currentTarget\n    const url = row.dataset.previewUrl\n    if (!url) return\n\n    const type = row.dataset.previewType\n    this.fileNameTarget.textContent = row.dataset.fileName || \"Preview\"\n    this.downloadLinkTarget.href = row.dataset.downloadUrl || url\n\n    if (type === \"pdf\") {\n      this.frameTarget.src = url\n      this.frameTarget.classList.remove(\"hidden\")\n      this.imageWrapperTarget.classList.add(\"hidden\")\n    } else if (type === \"image\") {\n      this.imageTarget.src = url\n      this.imageWrapperTarget.classList.remove(\"hidden\")\n      this.imageWrapperTarget.classList.add(\"flex\")\n      this.frameTarget.classList.add(\"hidden\")\n    }\n\n    this.modalTarget.showModal()          // native: focus trap + Esc + ::backdrop\n    document.body.classList.add(\"overflow-hidden\")   // stop the page behind scrolling\n  }\n\n  close() {\n    this.modalTarget.close()   // fires the \"close\" event -\u003e cleanup()\n  }\n\n  cleanup() {\n    document.body.classList.remove(\"overflow-hidden\")\n    // Blank the sources AFTER the close animation, so the old document doesn't\n    // visibly vanish mid-fade — and so it isn't still rendering in memory.\n    setTimeout(() =\u003e {\n      this.frameTarget.src = \"\"\n      this.imageTarget.src = \"\"\n    }, 300)\n  }\n\n  disconnect() {\n    document.body.classList.remove(\"overflow-hidden\")\n  }\n}\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **`disposition: \"inline\"` is the whole feature.** `rails_blob_path(file)` defaults to\n  `attachment`, which sends `Content-Disposition: attachment` — the browser downloads the\n  PDF instead of rendering it, and your iframe stays blank while a file lands in\n  `~/Downloads`. Build **two** URLs per row: `inline` for the iframe, `attachment` for the\n  download button.\n- **`min-h-0` on the flex body, or the iframe overflows the modal.** A flex child's default\n  `min-height: auto` refuses to shrink below its content, so `flex-1` alone lets the iframe\n  blow past `h-[90vh]` and the modal grows a second scrollbar. `flex-1 min-h-0` on the\n  wrapper + `h-full` on the iframe is the working combination.\n- **Esc bypasses your close handler.** `\u003cdialog\u003e` closes on Escape natively — your\n  `close()` action never runs, so `overflow-hidden` stays on `\u003cbody\u003e` (page looks frozen)\n  and the iframe keeps holding the old document. Listen for the dialog's own **`close`\n  event** and do cleanup there, then make your close button just call `.close()`. This is\n  the single most common bug in this pattern.\n- **Clear `iframe.src` on close — on a timer.** Clear it immediately and the viewer goes\n  white *during* the fade-out. Clear it never, and the next open flashes the previous\n  document for a beat before the new one paints. `setTimeout(..., 300)` matches the modal\n  animation.\n- **One dialog for the whole list.** Rendering a `\u003cdialog\u003e` per row means N iframes on the\n  page and N sets of duplicate Stimulus targets — Stimulus resolves `this.frameTarget` to\n  the *first* one, so every row previews the same file. Render one, swap its `src`.\n- **The `data-controller` wrapper must enclose the dialog too.** If the `\u003cdialog\u003e` sits\n  outside the wrapper (a common mistake when the modal is moved near `\u003c/body\u003e`), the\n  targets are out of scope and `open()` throws \"Missing target element\".\n- **Keep row action buttons outside the clickable area.** If the download/delete buttons\n  are inside the element carrying `click-\u003efile-preview#open`, clicking Delete also opens\n  the modal. Nest the clickable region as its own div, siblings for the actions.\n- **Turbo Stream re-renders are fine.** Replacing `#document_list` after an upload or\n  delete re-inserts rows with the `data-action` attributes; Stimulus re-binds them\n  automatically because it watches the DOM. Just never put the dialog inside the replaced\n  region — it would be destroyed while open.\n- **Not every browser renders PDFs in an iframe.** Mobile Safari and some Android browsers\n  ignore it or show a grey box. Always keep the download button in the modal header — it's\n  the fallback, not a nicety. Optionally render a \"having trouble? open in a new tab\" link.\n- **Non-previewable types must not be clickable.** Word/Excel/CSV in an iframe give a blank\n  panel or trigger a download behind the modal. That's why `preview_type` returns `nil` and\n  the view omits the data attributes entirely.\n- **Content Security Policy can block the iframe.** If your app sets a CSP with a\n  restrictive `frame-src`, add `'self'` (and your S3/CDN host if Active Storage redirects\n  there with `service_urls_expire_in`). Symptom: empty iframe plus a CSP violation in the\n  console.\n- **Font Awesome isn't guaranteed on every box.** The icons here are `fas fa-*`. If FA\n  isn't loaded, swap the `\u003ci\u003e` tags for inline SVGs — nothing in the logic depends on them.\n- **Private files still need authorization.** `rails_blob_path` URLs are signed but\n  guessable-forever if leaked. If documents are sensitive, proxy them through your own\n  controller that checks `current_user` before `send_data`/`redirect_to`, and point\n  `data-preview-url` at that instead.\n\n---\n\n## Files this pattern touches\n\n```\napp/models/company_file.rb                              # has_one_attached + content-type allowlist\napp/helpers/application_helper.rb                       # preview_type + file_type_icon\napp/views/company_files/_document_list.html.erb         # the clickable rows + data attributes\napp/views/companies/show.html.erb                       # the data-controller wrapper + \u003cdialog\u003e\napp/javascript/controllers/file_preview_controller.js   # open / close / cleanup\n```\n\n## How to adapt to your schema\n\n1. **Rename the model.** `CompanyFile` is just \"a record that `has_one_attached :file`\".\n   Swap in `Receipt`, `Invoice`, `Attachment`, or use `has_many_attached` and iterate\n   `record.files.each` — the row markup is per *attachment*, not per record.\n2. **Drop the parent scoping.** The example nests files under a company\n   (`current_user.companies.find(params[:company_id])`). If your files hang off the user\n   directly, delete the company plumbing; nothing in the modal cares.\n3. **Add types by extending `preview_type` only.** Want to preview plain text or video?\n   Return `\"text\"` / `\"video\"`, add a matching target element to the dialog, and add one\n   `else if` branch in `open()`. The row markup never changes.\n4. **Not on DaisyUI?** The only DaisyUI pieces are the `modal` / `modal-box` /\n   `modal-backdrop` classes. The behavior comes from the native `\u003cdialog\u003e` +\n   `showModal()`, which works with any CSS — style `dialog` and `dialog::backdrop`\n   yourself and keep everything else.\n5. **Not on Stimulus?** The controller is ~40 lines of plain DOM code. Bind a delegated\n   `click` listener on the list container and read the same `dataset` attributes.\n"}