empowered.house ↗

files/explorer.ex

File explorer

A file explorer with a folder tree on the left and the files of the selected folder in a list on the right.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.files_explorer animated />
</div>
default
isometric
custom tree and files
hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
path list ["Finance", "Invoices", "2026"] The breadcrumb, up to four short segments. The last one is the open folder.
folders list [%{name: "Finance", open: true, depth: 0}, %{name: "Invoices", ...}, ...] Up to eight `%{name, depth}` in tree order. Depth is 0, 1 or 2. Add `open: true` to a folder that shows its children. Keep names under 12 characters.
selected integer 2 Index of the folder whose files are listed.
files list [%{name: "Northwind-4471.pdf", size: "248 KB", modified: "12 Nov"}, %{name: "Acme-Tools-4470.pdf", ...}, ...] Up to six `%{name, size, modified}`. Keep names under 20 characters.
columns list ["Name", "Size", "Modified"] Three column names.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/files/explorer.ex
explorer.ex
defmodule EHVisuals.Files.Explorer do
  @moduledoc """
  A file explorer: the folder tree on the left with one folder open, and the
  files of that folder in a list on the right.
  """

  use EHVisuals.Composition,
    name: "files/explorer",
    title: "File explorer",
    description:
      "A file explorer with a folder tree on the left and the files of the selected folder in a list on the right."

  @folders [
    %{name: "Finance", depth: 0, open: true},
    %{name: "Invoices", depth: 1, open: true},
    %{name: "2026", depth: 2},
    %{name: "2025", depth: 2},
    %{name: "Contracts", depth: 1},
    %{name: "Month-end", depth: 1},
    %{name: "Operations", depth: 0}
  ]

  @files [
    %{name: "Northwind-4471.pdf", size: "248 KB", modified: "12 Nov"},
    %{name: "Acme-Tools-4470.pdf", size: "196 KB", modified: "11 Nov"},
    %{name: "Globex-4469.pdf", size: "312 KB", modified: "10 Nov"},
    %{name: "Invoices-Nov.xlsx", size: "84 KB", modified: "10 Nov"},
    %{name: "Initech-4468.pdf", size: "201 KB", modified: "07 Nov"}
  ]

  attr(:path, :list,
    default: ~w(Finance Invoices 2026),
    doc: "The breadcrumb, up to four short segments. The last one is the open folder."
  )

  attr(:folders, :list,
    default: @folders,
    doc:
      "Up to eight `%{name, depth}` in tree order. Depth is 0, 1 or 2. Add `open: true` to a folder that shows its children. Keep names under 12 characters."
  )

  attr(:selected, :integer, default: 2, doc: "Index of the folder whose files are listed.")

  attr(:files, :list,
    default: @files,
    doc: "Up to six `%{name, size, modified}`. Keep names under 20 characters."
  )

  attr(:columns, :list, default: ~w(Name Size Modified), doc: "Three column names.")

  attr(:label, :string,
    default: "A file explorer with a folder tree and a list of five invoice files"
  )

  frame_attrs()

  @spec files_explorer(map()) :: Phoenix.LiveView.Rendered.t()
  def files_explorer(assigns) do
    folders = Enum.take(assigns.folders, 8)
    files = Enum.take(assigns.files, 6)
    selected = assigns.selected |> max(0) |> min(length(folders) - 1)

    tree =
      for {folder, index} <- Enum.with_index(folders) do
        depth = folder |> Map.get(:depth, 0) |> max(0) |> min(2)
        x = 14 + depth * 14
        cy = 60 + index * 24

        %{
          index: index,
          name: folder.name,
          y: 48 + index * 24,
          cy: cy,
          text_x: x + 30,
          selected: index == selected,
          chevron: chevron_d(x, cy, Map.get(folder, :open, false)),
          folder: folder_d(x + 11, cy - 5)
        }
      end

    rows =
      for {file, index} <- Enum.with_index(files) do
        y = 67 + index * 32
        Map.merge(file, %{index: index, y: y, page: page_d(166.5, y + 8.5, 12, 15, 4)})
      end

    assigns =
      assigns
      |> assign(:tree, tree)
      |> assign(:rows, rows)
      |> assign(:crumbs, assigns.path |> Enum.take(4) |> Enum.with_index())
      |> assign(:last_crumb, min(length(assigns.path), 4) - 1)
      |> assign(:count, "#{length(files)} files")
      |> assign(:height, max(48 + length(tree) * 24, 67 + length(rows) * 32) + 10)

    ~H"""
    <.frame name="files/explorer" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 440 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="439" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="20" y="24.5" font-size="8.5">
              <tspan
                :for={{crumb, index} <- @crumbs}
                class={["ehv-mono", index == @last_crumb && "ehv-t-ink"]}
              >
                {if index > 0, do: " / "}{String.upcase(crumb)}
              </tspan>
            </text>
            <text class="ehv-mono" x="420" y="24.5" font-size="8.5" text-anchor="end">
              {String.upcase(@count)}
            </text>
            <line class="ehv-line-soft" x1="0.5" x2="439.5" y1="40.5" y2="40.5" />
            <line class="ehv-line-faint" x1="150.5" x2="150.5" y1="41" y2={@height - 0.5} />
          </g>
          <g :for={node <- @tree} class={"ehv-a-slide ehv-i-#{min(node.index + 2, 24)}"}>
            <rect
              :if={node.selected}
              class="ehv-fill-surface-3"
              x="6"
              y={node.y + 1}
              width="138"
              height="22"
              rx="6"
            />
            <path class="ehv-line ehv-round" d={node.chevron} />
            <path
              class={[if(node.selected, do: "ehv-line-lime", else: "ehv-line-ink"), "ehv-round"]}
              d={node.folder}
            />
            <text
              class={if node.selected, do: "ehv-t-strong", else: "ehv-t-2"}
              x={node.text_x}
              y={node.cy + 4}
              font-size="11"
            >
              {node.name}
            </text>
          </g>
          <g class="ehv-a-fade ehv-i-4">
            <text class="ehv-mono" x="166" y="58" font-size="7.5">
              {String.upcase(Enum.at(@columns, 0, ""))}
            </text>
            <text class="ehv-mono" x="350" y="58" font-size="7.5" text-anchor="end">
              {String.upcase(Enum.at(@columns, 1, ""))}
            </text>
            <text class="ehv-mono" x="420" y="58" font-size="7.5" text-anchor="end">
              {String.upcase(Enum.at(@columns, 2, ""))}
            </text>
            <line class="ehv-line-soft" x1="166" x2="420" y1="66.5" y2="66.5" />
          </g>
          <g
            :for={row <- @rows}
            class={["ehv-files-explorer__row", "ehv-a-rise", "ehv-i-#{row.index + 5}"]}
          >
            <rect class="ehv-files-explorer__bg" x="158" y={row.y + 2} width="270" height="28" rx="6" />
            <line
              :if={row.index > 0}
              class="ehv-line-faint"
              x1="166"
              x2="420"
              y1={row.y + 0.5}
              y2={row.y + 0.5}
            />
            <path class="ehv-card-3 ehv-round" d={row.page} />
            <text x="187" y={row.y + 20} font-size="11.5">{row.name}</text>
            <text class="ehv-mono ehv-t-2" x="350" y={row.y + 19.5} font-size="8" text-anchor="end">
              {String.upcase(row.size)}
            </text>
            <text class="ehv-mono" x="420" y={row.y + 19.5} font-size="8" text-anchor="end">
              {String.upcase(row.modified)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # A chevron: down for an open folder, right for a closed one.
  defp chevron_d(x, cy, true), do: "M#{x} #{cy - 1.5} l3 3 l3 -3"
  defp chevron_d(x, cy, _closed), do: "M#{x + 1.5} #{cy - 3} l3 3 l-3 3"

  # A folder, 13 wide and 10 high, with its tab on the left.
  defp folder_d(x, y) do
    "M#{x} #{y + 1.5} a1.5 1.5 0 0 1 1.5 -1.5 h3 l1.5 1.8 h5.5 a1.5 1.5 0 0 1 1.5 1.5 " <>
      "v5.2 a1.5 1.5 0 0 1 -1.5 1.5 h-10 a1.5 1.5 0 0 1 -1.5 -1.5 z"
  end

  # A page with a folded corner.
  defp page_d(x, y, w, h, fold) do
    "M#{x} #{y} h#{w - fold} l#{fold} #{fold} v#{h - fold} h-#{w} z " <>
      "M#{x + w - fold} #{y} v#{fold} h#{fold}"
  end
end
assets/css/files/explorer.css
explorer.css
/* files/explorer */
.ehv-files-explorer { --ehv-max-w: 480px; }

/* A file row lights up under the pointer. */
.ehv .ehv-files-explorer__bg { fill: transparent; }
.ehv--hover .ehv-files-explorer__bg { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-files-explorer__row:hover .ehv-files-explorer__bg { fill: var(--ehv-surface-2); }