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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.files_explorer animated />
</div>
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
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
/* 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); }