Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sxyazi/yazi/llms.txt

Use this file to discover all available pages before exploring further.

Previewer plugins generate visual previews of files in Yazi’s preview pane. They can display text, images, videos, archives, and any custom format.

What Are Previewers?

Previewers show file contents in the right pane of Yazi. Built-in previewers handle:
  • Code files with syntax highlighting
  • Images (PNG, JPEG, WebP, etc.)
  • Videos (thumbnails/frames)
  • Archives (file listings)
  • PDFs (rendered pages)
  • Folders (directory listings)

Previewer Structure

Previewers implement these methods:
local M = {}

-- Required: Generate preview
function M:peek(job)
  -- Display preview
end

-- Required: Handle scrolling
function M:seek(job)
  -- Respond to scroll events
end

-- Optional: Preload/cache content
function M:preload(job)
  -- Prepare content in background
  return true  -- or false to skip preview
end

return M

The Job Object

function M:peek(job)
  job.file      -- File object (url, name, cha, etc.)
  job.area      -- Preview area (x, y, w, h)
  job.skip      -- Scroll offset (lines or pages)
  job.args      -- Additional arguments
end

Example: Image Previewer

Here’s Yazi’s built-in image previewer:
local M = {}

function M:peek(job)
  local start, url = os.clock(), ya.file_cache(job)
  if not url or not fs.cha(url) then
    url = job.file.url
  end
  
  -- Wait for image delay
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  -- Show image in preview area
  local _, err = ya.image_show(url, job.area)
  ya.preview_widget(job, err)
end

function M:seek() end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache or fs.cha(cache) then
    return true
  end
  
  -- Precache image for faster display
  return ya.image_precache(job.file.url, cache)
end

return M
Configure in yazi.toml:
[[plugin.previewers]]
mime = "image/*"
run = "image"

Example: Code Previewer

Display code files with syntax highlighting:
local M = {}

function M:peek(job)
  local err, bound = ya.preview_code(job)
  if bound then
    -- Scrolled past end, emit new peek with correct bound
    ya.emit("peek", {
      bound,
      only_if = job.file.url,
      upper_bound = true
    })
  elseif err and not err:find("cancelled", 1, true) then
    -- Show error message
    require("empty").msg(job, err)
  end
end

function M:seek(job)
  local h = cx.active.current.hovered
  if not h or h.url ~= job.file.url then
    return
  end
  
  -- Calculate scroll step
  local step = math.floor(job.units * job.area.h / 10)
  step = step == 0 and ya.clamp(-1, job.units, 1) or step
  
  -- Emit new peek with updated offset
  ya.emit("peek", {
    math.max(0, cx.active.preview.skip + step),
    only_if = job.file.url,
  })
end

return M

Example: Video Previewer

Extract video frames as previews:
local M = {}

function M:peek(job)
  local start, cache = os.clock(), ya.file_cache(job)
  if not cache then
    return
  end
  
  local ok, err = self:preload(job)
  if not ok or err then
    return ya.preview_widget(job, err)
  end
  
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  local _, err = ya.image_show(cache, job.area)
  ya.preview_widget(job, err)
end

function M:seek(job)
  local h = cx.active.current.hovered
  if h and h.url == job.file.url then
    ya.emit("peek", {
      math.max(0, cx.active.preview.skip + job.units),
      only_if = job.file.url,
    })
  end
end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache then
    return true
  end
  
  local cha = fs.cha(cache)
  if cha and cha.len > 0 then
    return true
  end
  
  -- Get video duration
  local meta, err = self.list_meta(
    job.file.path,
    "format=duration:stream_disposition=attached_pic"
  )
  if not meta then
    return true, err
  elseif not meta.format.duration then
    return true, Err("Failed to get video duration")
  end
  
  -- Calculate frame position
  local percent = 5 + job.skip
  if percent > 95 then
    ya.emit("peek", { 90, only_if = job.file.url, upper_bound = true })
    return false
  end
  
  -- Extract frame with ffmpeg
  local output, err = Command("ffmpeg")
    :arg({
      "-v", "warning",
      "-hwaccel", "auto",
      "-ss", math.floor(meta.format.duration * percent / 100),
      "-skip_frame", "nokey",
      "-i", tostring(job.file.path),
      "-vframes", 1,
      "-q:v", 31 - math.floor(rt.preview.image_quality * 0.3),
      "-vf", string.format(
        "scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease",
        rt.preview.max_width,
        rt.preview.max_height
      ),
      "-f", "image2",
      "-y", tostring(cache),
    })
    :stderr(Command.PIPED)
    :output()
  
  if not output then
    return true, Err("Failed to start ffmpeg: %s", err)
  elseif output.status.success then
    return true
  else
    return false, Err("ffmpeg error: %s", output.stderr)
  end
end

function M.list_meta(path, entries)
  local output, err = Command("ffprobe")
    :arg({
      "-v", "quiet",
      "-show_entries", entries,
      "-of", "json=c=1",
      tostring(path)
    })
    :output()
  
  if not output then
    return nil, Err("Failed to start ffprobe: %s", err)
  end
  
  local t = ya.json_decode(output.stdout)
  if not t then
    return nil, Err("Failed to decode ffprobe output")
  end
  
  t.format = t.format or {}
  t.streams = t.streams or {}
  return t
end

return M

Example: PDF Previewer

Render PDF pages as images:
local M = {}

function M:peek(job)
  local start, cache = os.clock(), ya.file_cache(job)
  if not cache then
    return
  end
  
  local ok, err, bound = self:preload(job)
  if bound and bound > 0 then
    -- User scrolled past last page
    return ya.emit("peek", {
      bound - 1,
      only_if = job.file.url,
      upper_bound = true
    })
  elseif not ok or err then
    return ya.preview_widget(job, err)
  end
  
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  local _, err = ya.image_show(cache, job.area)
  ya.preview_widget(job, err)
end

function M:seek(job)
  local h = cx.active.current.hovered
  if h and h.url == job.file.url then
    local step = ya.clamp(-1, job.units, 1)
    ya.emit("peek", {
      math.max(0, cx.active.preview.skip + step),
      only_if = job.file.url
    })
  end
end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache or fs.cha(cache) then
    return true
  end
  
  -- Convert PDF page to JPEG with pdftoppm
  local output, err = Command("pdftoppm")
    :arg({
      "-f", job.skip + 1,     -- First page
      "-l", job.skip + 1,     -- Last page
      "-singlefile",
      "-jpeg",
      "-jpegopt", "quality=" .. rt.preview.image_quality,
      tostring(job.file.path),
      tostring(cache),
    })
    :output()
  
  if not output then
    return true, Err("Failed to start pdftoppm: %s", err)
  elseif not output.status.success then
    -- Extract page count from error
    local pages = job.skip > 0 and tonumber(
      output.stderr:match("the last page %((%d+)%)")
    )
    return true, Err("Failed to convert PDF: %s", output.stderr), pages
  end
  
  -- pdftoppm adds .jpg extension
  return ya.image_precache(Url(cache .. ".jpg"), cache)
end

return M

Example: Archive Previewer

List contents of archive files:
local M = {}

function M:peek(job)
  local limit = job.area.h
  local files, err = self.list_archive(
    { "-p", tostring(job.file.path) },
    job.skip,
    limit
  )
  
  if err then
    return ya.preview_widget(job, err)
  elseif job.skip > 0 and #files < job.skip + limit then
    return ya.emit("peek", {
      math.max(0, #files - limit),
      only_if = job.file.url,
      upper_bound = true
    })
  end
  
  -- Build file list display
  local left, right = {}, {}
  for i = job.skip + 1, #files do
    local f = files[i]
    local icon = File({
      url = Url(f.path),
      cha = Cha { mode = tonumber(f.is_dir and "40700" or "100644", 8) },
    }):icon()
    
    -- Right side: size
    if f.size > 0 then
      right[#right + 1] = " " .. ya.readable_size(f.size) .. " "
    else
      right[#right + 1] = " "
    end
    
    -- Left side: icon and name
    if icon then
      left[#left + 1] = ui.Span(" " .. icon.text .. " "):style(icon.style)
    else
      left[#left + 1] = " "
    end
    
    left[#left] = ui.Line {
      string.rep(" │", f.depth),
      left[#left],
      ui.truncate(f.path.name or tostring(f.path), {
        rtl = true,
        max = math.max(0, job.area.w - (f.depth * 2) 
          - ui.width(left[#left]) - ui.width(right[#right])),
      }),
    }
  end
  
  ya.preview_widget(job, {
    ui.Text(left):area(job.area),
    ui.Text(right):area(job.area):align(ui.Align.RIGHT),
  })
end

function M:seek(job)
  require("code"):seek(job)
end

function M.list_archive(args, skip, limit)
  local child = M.spawn_7z({
    "l", "-ba", "-slt", "-sccUTF-8",
    table.unpack(args)
  })
  
  if not child then
    return {}, Err("Failed to start 7-zip")
  end
  
  local files = {}
  -- Parse 7-zip output...
  -- (Implementation details omitted)
  
  child:start_kill()
  return files, nil
end

function M.spawn_7z(args)
  local child = Command("7zz"):arg(args):stdout(Command.PIPED):spawn()
  if not child then
    child = Command("7z"):arg(args):stdout(Command.PIPED):spawn()
  end
  return child
end

return M

Preview Widgets

Display Text

local text = ui.Text("content\nline 2\nline 3")
  :area(job.area)
  :wrap(ui.Wrap.YES)
  :align(ui.Align.CENTER)

ya.preview_widget(job, text)

Display Images

local _, err = ya.image_show(url, job.area)
ya.preview_widget(job, err)

Display List

local lines = {
  ui.Line("item 1"),
  ui.Line("item 2"),
  ui.Line("item 3"),
}

ya.preview_widget(job, {
  ui.List(lines):area(job.area),
})

Display Error

ya.preview_widget(job, Err("Failed to load: %s", error_msg))

Caching

Use ya.file_cache() to get cache paths:
function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache then
    return true  -- No cache available
  end
  
  -- Check if already cached
  if fs.cha(cache) then
    return true  -- Cache exists
  end
  
  -- Generate cache
  Command("convert")
    :arg({ tostring(job.file.path), tostring(cache) })
    :status()
  
  return true
end

Scrolling

Handle scroll with seek():
function M:seek(job)
  local h = cx.active.current.hovered
  if not h or h.url ~= job.file.url then
    return
  end
  
  -- job.units: positive = down, negative = up
  local step = job.units
  
  -- Emit new peek with updated offset
  ya.emit("peek", {
    math.max(0, cx.active.preview.skip + step),
    only_if = job.file.url,
  })
end

Upper Bounds

Tell Yazi the maximum scroll position:
if job.skip > max_lines then
  ya.emit("peek", {
    max_lines,  -- Correct upper bound
    only_if = job.file.url,
    upper_bound = true
  })
  return
end

Best Practices

Even if scrolling isn’t supported:
function M:seek() end
Generate previews in background:
function M:preload(job)
  -- Heavy computation here
  return true
end
Check if external tools exist:
local output, err = Command("tool"):output()
if not output then
  return ya.preview_widget(job, 
    Err("tool not found: %s", err))
end
Check configuration:
if width > rt.preview.max_width or 
   height > rt.preview.max_height then
  -- Resize or reject
end

Next Steps

Fetchers

Create metadata fetchers

UI Plugins

Customize the interface

Build docs developers (and LLMs) love