empowered.house ↗

files/stacked.ex

Stacked files

A stack of overlapping file cards with depth: the top one readable, the ones behind showing their header.

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

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

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
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
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); }