Stop Using fzf Like a Beginner: Keybindings, Previews, and FZF_DEFAULT_OPTS

Most people install fzf, wire it to Ctrl+R for history search, feel clever, and then never touch it again. That’s fine — but it’s roughly equivalent to buying a CNC machine to cut cardboard.

fzf is a full interactive TUI framework. You can build git log browsers, live-reloading process killers, Kubernetes pod log viewers, and SSH host pickers — all in under 20 lines of shell. The gap between "I use fzf for history" and "fzf is the backbone of my terminal workflow" is this article.

Official repo (read the CHANGELOG — it ships features constantly): https://github.com/junegunn/fzf


First: Get FZF_DEFAULT_OPTS Under Control

Every fzf invocation inherits options from FZF_DEFAULT_OPTS. This is where you set your global personality once and stop repeating --layout=reverse --border on every call.

Here’s a solid baseline to put in your .bashrc or .zshrc:

export FZF_DEFAULT_OPTS="
  --height=50%
  --layout=reverse
  --border=rounded
  --info=inline
  --prompt='❯ '
  --pointer='▶'
  --marker='✓'
  --color=fg:#cdd6f4,bg:#1e1e2e,hl:#f38ba8
  --color=fg+:#cdd6f4,bg+:#313244,hl+:#f38ba8
  --color=info:#cba6f7,prompt:#89dceb,pointer:#f5c2e7
  --color=marker:#a6e3a1,spinner:#f5c2e7,border:#6c7086
  --bind='ctrl-/:toggle-preview'
  --bind='ctrl-space:toggle+down'
  --bind='ctrl-a:select-all'
  --bind='ctrl-d:deselect-all'
"

This sets the Catppuccin Mocha color scheme, a compact 50% height panel, and a handful of bindings that every interactive session will inherit automatically.

Gotcha #1: Word splitting will bite you. If you put FZF_DEFAULT_OPTS on one line with multiple --color flags, make sure the shell isn’t splitting on the commas inside color values. The multiline heredoc-style assignment above (quoting the whole thing) is the safest form. Never use an unquoted variable assignment for this.

Gotcha #2: Per-call flags override FZF_DEFAULT_OPTS for the same option, but --bind accumulates. So if you bind ctrl-/ globally and also pass --bind='ctrl-/:something-else' in a script, the last one wins for that key. No merging logic — last writer takes it.


The Preview Window: Your Second Brain

The --preview flag takes a shell command. {} is replaced by the current selection. That’s it — the rest is just shell.

# Files with syntax highlighting (requires bat)
fzf --preview 'bat --color=always --style=numbers {}'

# Directories with tree
fzf --preview '[[ -d {} ]] && tree -C {} || bat --color=always {}'

# Git commits
git log --oneline | fzf --preview 'git show --color=always {1}'

Controlling the Preview Window

--preview-window is where the real control lives:

fzf \
  --preview 'bat --color=always {}' \
  --preview-window 'right:60%:border-left:wrap'

The format is position:size:border:options. Position options: up, down, left, right. You can stack modifiers:

  • hidden — preview starts hidden, toggled by a bind
  • wrap — word-wrap long lines
  • follow — auto-scroll to the bottom (great for log tails)
  • noinfo — hide the line count header
  • border-left, border-rounded, border-none — border style

A real-world pattern I use constantly — start preview hidden to keep the list uncluttered, toggle on demand:

export FZF_DEFAULT_OPTS="
  ...
  --preview-window='right:55%:hidden:border-left'
  --bind='ctrl-/:toggle-preview'
"

Dynamic Preview with {q} and Field Placeholders

{} gives you the whole selected line. But fzf has more placeholders:

Placeholder Meaning
{} Full selected line
{1}, {2} Tab/space-delimited fields (1-indexed)
{+} All selected items (with --multi)
{q} Current query string
{n} 0-indexed line number

This lets you do things like pipe kubectl get pods output and extract only the pod name for the preview:

kubectl get pods --all-namespaces | \
  fzf --header-lines=1 \
      --preview 'kubectl logs --tail=50 -n {1} {2}' \
      --preview-window 'down:50%:wrap'

{1} is the namespace, {2} is the pod name. Clean.


Keybindings: The --bind Rabbit Hole

--bind is the most powerful fzf flag. The format is key:action or key:action1+action2 to chain. Pass multiple bindings either comma-separated or with repeated --bind flags.

Actions You Should Know

Navigation and selection:

up / down / page-up / page-down
toggle          -- toggle selection of current item
toggle-all      -- toggle all items
select-all      -- select all matched items
deselect-all    -- clear all selections

Preview control:

toggle-preview
preview-up / preview-down
preview-page-up / preview-page-down
preview-top / preview-bottom
change-preview(cmd)   -- change the preview command dynamically

The power trio — execute, reload, transform:

execute(cmd)         -- run cmd without leaving fzf
execute-silent(cmd)  -- run cmd, suppress output
reload(cmd)          -- re-run the input command and refresh the list

execute vs execute-silent

execute takes over the terminal, runs the command, then returns to fzf. Use it when you need to see output — like opening a file in vim or tailing logs:

fzf --bind 'enter:execute(vim {})'

execute-silent runs in the background without touching the terminal. Perfect for things like copying to clipboard, sending a kill signal, or triggering a webhook:

# Kill a process without leaving fzf
ps aux | fzf --multi \
  --bind 'ctrl-k:execute-silent(echo {} | awk "{print \$2}" | xargs kill)+reload(ps aux)'

Notice the +reload(ps aux) chained on — after killing, refresh the list. This is the pattern for building interactive managers.

Gotcha #3: Quoting inside execute(...) is a nightmare. You’re writing a shell command inside a shell string inside another shell string. Single-quote the outer fzf bind, double-quote inside the command, and avoid nesting further. When it gets complex, extract the logic into a helper function or script and call that instead:

# Don't do this monstrosity inline
# Do this instead:
_kill_selected() {
  echo "$1" | awk '{print $2}' | xargs kill -9
}
export -f _kill_selected

ps aux | fzf --multi \
  --bind 'ctrl-k:execute-silent(_kill_selected {})+reload(ps aux)'

reload for Dynamic Lists

reload is what separates fzf "interactive tool" from fzf "menu". The change:reload(...) event re-runs your data source every time the query string changes:

# Live ripgrep integration — search file CONTENTS interactively
fzf \
  --disabled \
  --ansi \
  --bind 'change:reload(rg --color=always --line-number {q} || true)' \
  --delimiter ':' \
  --preview 'bat --color=always {1} --highlight-line {2}' \
  --preview-window 'right:60%:border-left:+{2}+3/3' \
  --bind 'enter:execute(vim {1} +{2})'

--disabled turns off fzf’s own fuzzy matching — your reload command is doing the filtering instead. This is the standard pattern for "use fzf as the UI shell, rg as the engine."

The +{2}+3/3 in --preview-window is fzf’s scroll offset syntax: scroll the preview to line {2} (the matched line number), with 3 lines of context, keeping it at 1/3 from the top.


Sub-commands: fzf in Scripts

The Basics

fzf reads stdin, writes stdout. That’s the whole contract. In a script:

#!/usr/bin/env bash

selected=$(ls /etc | fzf --prompt='Config file: ')
[[ -z "$selected" ]] && exit 0   # user hit Esc

echo "You picked: $selected"

Always check for empty output — the user might press Esc or Ctrl+C, and you shouldn’t proceed with an empty variable.

Multi-select

files=$(find . -type f -name '*.log' | fzf --multi --prompt='Logs to archive: ')
[[ -z "$files" ]] && exit 0

# $files is newline-delimited
while IFS= read -r file; do
  gzip "$file"
done <<< "$files"

For paths with spaces, use --print0 and --read0 together:

# Sender side
find . -type f -print0 | fzf --multi --read0 --print0 | xargs -0 process_files

Building a Reusable Menu Function

#!/usr/bin/env bash

# Usage: pick_from "prompt" item1 item2 item3...
pick_from() {
  local prompt="$1"; shift
  printf '%s\n' "$@" | fzf \
    --prompt="$prompt " \
    --height=40% \
    --layout=reverse \
    --border=rounded
}

env=$(pick_from "Deploy to:" staging production canary)
[[ -z "$env" ]] && { echo "Cancelled."; exit 0; }

echo "Deploying to $env..."

SSH Host Picker

One of the most practically useful things you can build in 10 lines:

#!/usr/bin/env bash

host=$(
  grep -E '^Host ' ~/.ssh/config \
    | awk '{print $2}' \
    | grep -v '\*' \
    | fzf \
        --prompt='SSH ❯ ' \
        --preview 'grep -A5 "^Host {}" ~/.ssh/config' \
        --preview-window='down:30%:border-top'
)

[[ -z "$host" ]] && exit 0
exec ssh "$host"

Wire this to a keybinding in your terminal emulator or shell, and you’ll never manually type an SSH hostname again.

Git Workflow: Checkout Branch with Preview

#!/usr/bin/env bash

branch=$(
  git branch --all --sort=-committerdate \
    | grep -v HEAD \
    | sed 's/^[ *]*//' \
    | sed 's|remotes/origin/||' \
    | sort -u \
    | fzf \
        --prompt='Branch: ' \
        --preview 'git log --oneline --color=always -20 {1}' \
        --preview-window='right:55%:border-left'
)

[[ -z "$branch" ]] && exit 0
git checkout "$branch"

Gotcha #4: git branch output has leading spaces and asterisks. Always strip them with sed before piping to fzf, or your checkout command will fail with * main as the branch name.


FZF_DEFAULT_COMMAND: What Feeds fzf by Default

When you run fzf with no stdin in an interactive shell, it falls back to FZF_DEFAULT_COMMAND. The default is the system find, which is slow and noisy. Replace it:

# Use fd — respects .gitignore, faster, saner defaults
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'

# Or ripgrep (lists files only, no content search)
export FZF_DEFAULT_COMMAND='rg --files --hidden --follow --glob "!.git/*"'

This also affects Ctrl+T if you use the fzf shell integration. Speaking of which — make sure you source the shell integration in your rc file:

# Bash
eval "$(fzf --bash)"

# Zsh
eval "$(fzf --zsh)"

# Fish
fzf --fish | source

This wires up Ctrl+T (file picker), Ctrl+R (history), and Alt+C (cd into directory) with sane defaults that respect your FZF_DEFAULT_COMMAND and FZF_DEFAULT_OPTS.


A Complete Real-World Config

Here’s how all of this looks assembled together in a .bashrc / .zshrc:

# ─── fzf ──────────────────────────────────────────────────────────────────────

export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git'

export FZF_DEFAULT_OPTS="
  --height=50%
  --layout=reverse
  --border=rounded
  --info=inline
  --prompt='❯ '
  --pointer='▶'
  --marker='✓'
  --bind='ctrl-/:toggle-preview'
  --bind='ctrl-a:select-all'
  --bind='ctrl-d:deselect-all'
  --bind='ctrl-space:toggle+down'
  --preview-window='right:55%:hidden:border-left'
  --color=fg:#cdd6f4,bg:#1e1e2e,hl:#f38ba8
  --color=fg+:#cdd6f4,bg+:#313244,hl+:#f38ba8
  --color=info:#cba6f7,prompt:#89dceb,pointer:#f5c2e7
  --color=marker:#a6e3a1,spinner:#f5c2e7,border:#6c7086
"

# Ctrl+T: preview files with bat
export FZF_CTRL_T_OPTS="
  --preview 'bat --color=always --style=numbers --line-range=:300 {}'
  --preview-window='right:55%:border-left'
"

# Alt+C: preview directories with tree
export FZF_ALT_C_OPTS="
  --preview 'tree -C {} | head -50'
  --preview-window='right:40%:border-left'
"

# Ctrl+R: show full command, date, and directory context
export FZF_CTRL_R_OPTS="
  --preview 'echo {}'
  --preview-window='down:3:wrap:border-top'
  --bind='ctrl-/:toggle-preview'
"

eval "$(fzf --bash)"   # or --zsh / --fish

Gotchas Summary

--preview runs on every cursor move. If your preview command is slow (network calls, heavy computation), users will notice lag. Keep previews fast — bat, head, tree, pre-cached data. Never put docker exec or kubectl exec in a preview command.

{+} in multi-select is space-separated, not newline-separated. When you use execute({+}) with multiple selected items, they arrive in one string. If paths have spaces, use {+f} — fzf writes the selections to a temp file instead.

--disabled doesn’t mean "no filtering." It disables fzf’s fuzzy algorithm. Your reload command still runs on every keystroke change. Debouncing is not built in — for heavy backends, throttle at the shell level or accept the query latency.

Color codes from tools like ls --color or tree -C need --ansi to render. Without it, you’ll see raw ANSI escape codes as literal text. Add --ansi to your call whenever the input source emits color.

Escape handling in --bind with single quotes: if the outer --bind string is single-quoted, you can’t use single quotes inside. Use $'...' ANSI-C quoting for complex inner commands, or extract them to functions.


The pattern that ties everything together: fzf is a dumb pipe with a smart TUI layer. You handle data, it handles interaction. Once that mental model clicks, you stop thinking "how do I fzf this" and start building actual tools — git GUIs, deployment menus, log browsers — all without leaving your terminal.

👁 Views: 202,849 · Unique visitors: 57,406