files/stacked.ex
Stacked files
A stack of overlapping file cards with depth: the top one readable, the ones behind showing their header.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.files_stacked animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
files |
list | [%{name: "Northwind-4471.pdf", size: "248 KB", type: "PDF"}, %{name: "Delivery-note-881.pdf", ...}, ...] |
Two to four `%{name, type, size}`. The first is the top card. Keep names under 26 characters. |
note |
string | "Matched to PO-2291" |
What is known about the top file, with a check mark. Up to 34 characters. |
footer |
any | nil |
The line at the bottom of the top card. Left `nil`, it counts the files. |
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/stacked.ex
defmodule EHVisuals.Files.Stacked do
@moduledoc """
A stack of file cards, one behind the other. The top card is readable; the
cards behind it show only their header.
"""
use EHVisuals.Composition,
name: "files/stacked",
title: "Stacked files",
description:
"A stack of overlapping file cards with depth: the top one readable, the ones behind showing their header."
@files [
%{name: "Northwind-4471.pdf", type: "PDF", size: "248 KB"},
%{name: "Delivery-note-881.pdf", type: "PDF", size: "96 KB"},
%{name: "Order-lines.xlsx", type: "XLSX", size: "84 KB"}
]
attr(:files, :list,
default: @files,
doc:
"Two to four `%{name, type, size}`. The first is the top card. Keep names under 26 characters."
)
attr(:note, :string,
default: "Matched to PO-2291",
doc: "What is known about the top file, with a check mark. Up to 34 characters."
)
attr(:footer, :any,
default: nil,
doc: "The line at the bottom of the top card. Left `nil`, it counts the files."
)
attr(:label, :string,
default: "Three file cards stacked behind each other, with an invoice on top"
)
frame_attrs()
@spec files_stacked(map()) :: Phoenix.LiveView.Rendered.t()
def files_stacked(assigns) do
files = Enum.take(assigns.files, 4)
count = length(files)
[top | rest] = files
front_y = 20 + (count - 1) * 26
# Farthest card first, so the nearer ones are drawn over it.
backs =
rest
|> Enum.with_index(1)
|> Enum.map(fn {file, depth} ->
x = 20 + depth * 20
y = front_y - depth * 26
Map.merge(file, %{
depth: depth,
order: count - 1 - depth,
x: x,
y: y,
page: page_d(x + 14.5, y + 6.5, 10, 13, 3.5)
})
end)
|> Enum.reverse()
assigns =
assigns
|> assign(:backs, backs)
|> assign(:top, top)
|> assign(:count, count)
|> assign(:y, front_y)
|> assign(:page, page_d(36.5, front_y + 18.5, 30, 38, 9))
|> assign(:meta, "#{top.type} · #{top.size}")
|> assign(:summary, assigns.footer || "Top of #{count} files in this batch")
|> assign(:position, "1 / #{count}")
|> assign(:width, 280 + (count - 1) * 20 + 40)
|> assign(:height, front_y + 150 + 20)
~H"""
<.frame name="files/stacked" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 #{@width} #{@height}"}>
<g class="ehv-h-lift">
<g
:for={card <- @backs}
class={["ehv-files-stacked__back", "ehv-files-stacked__back--#{card.depth}"]}
>
<g class={"ehv-a-rise ehv-i-#{card.order}"}>
<rect
class="ehv-card"
x={card.x + 0.5}
y={card.y + 0.5}
width="279"
height="149"
rx="12"
/>
<path class="ehv-line ehv-round" d={card.page} />
<text class="ehv-t-3" x={card.x + 32} y={card.y + 17} font-size="10.5">
{card.name}
</text>
<text
class="ehv-mono ehv-t-4"
x={card.x + 266}
y={card.y + 16.5}
font-size="7.5"
text-anchor="end"
>
{String.upcase(card.size)}
</text>
</g>
</g>
<g class={"ehv-a-rise ehv-i-#{@count - 1}"}>
<rect class="ehv-card" x="20.5" y={@y + 0.5} width="279" height="149" rx="12" />
<path class="ehv-card-3 ehv-round" d={@page} />
<text class="ehv-mono ehv-t-2" x="51.5" y={@y + 49} font-size="6.5" text-anchor="middle">
{String.upcase(@top.type)}
</text>
<text class="ehv-t-strong" x="80" y={@y + 36} font-size="13.5">{@top.name}</text>
<text class="ehv-mono ehv-t-2" x="80" y={@y + 52} font-size="8.5">
{String.upcase(@meta)}
</text>
<line class="ehv-line-faint" x1="36" x2="284" y1={@y + 72.5} y2={@y + 72.5} />
<circle class="ehv-fill-lime-soft" cx="46" cy={@y + 93} r="9" />
<path
class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{@count + 2}"}
d={"M42 #{@y + 93} l2.8 2.8 l5 -5.6"}
pathLength="1"
/>
<text x="64" y={@y + 97} font-size="12">{@note}</text>
<line class="ehv-line-faint" x1="36" x2="284" y1={@y + 114.5} y2={@y + 114.5} />
<text class="ehv-mono" x="36" y={@y + 134} font-size="8">{String.upcase(@summary)}</text>
<text class="ehv-mono ehv-t-2" x="284" y={@y + 134} font-size="8" text-anchor="end">
{@position}
</text>
</g>
</g>
</svg>
</.frame>
"""
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/stacked.css
/* files/stacked */
.ehv-files-stacked { --ehv-max-w: 400px; }
/* On hover the cards behind fan out a little further. */
.ehv-files-stacked__back { transform-box: fill-box; }
.ehv--hover .ehv-files-stacked__back { transition: transform 260ms var(--ehv-ease); }
.ehv--hover:hover .ehv-files-stacked__back--1 { transform: translate(5px, -5px); }
.ehv--hover:hover .ehv-files-stacked__back--2 { transform: translate(10px, -10px); }
.ehv--hover:hover .ehv-files-stacked__back--3 { transform: translate(15px, -15px); }