sections/blog.ex
Blog
A blog index section with three article cards, each with an abstract cover, a category, a title and a date.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.sections_blog animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
eyebrow |
string | "Blog" |
|
heading |
string | "Notes from operations" |
|
link |
string | "All posts" |
The link on the right of the heading. |
posts |
list | [%{date: "12 Nov 2026", title: "Month-end close in three days", category: "Finance"}, %{date: "4 Nov 2026", ...}, ...] |
One to three `%{category, title, date}`. A title wraps onto up to three lines of about 15 characters. |
featured |
integer | 0 |
Index of the post marked in lime. A negative index marks none. |
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/sections/blog.ex
defmodule EHVisuals.Sections.Blog do
@moduledoc """
A blog index section: article cards with an abstract cover, a category, a title and a date.
"""
use EHVisuals.Composition,
name: "sections/blog",
title: "Blog",
description:
"A blog index section with three article cards, each with an abstract cover, a category, a title and a date."
@posts [
%{category: "Finance", title: "Month-end close in three days", date: "12 Nov 2026"},
%{category: "Agents", title: "Agents and the late invoice", date: "4 Nov 2026"},
%{category: "Operations", title: "One ERP, four warehouses", date: "28 Oct 2026"}
]
attr(:eyebrow, :string, default: "Blog")
attr(:heading, :string, default: "Notes from operations")
attr(:link, :string, default: "All posts", doc: "The link on the right of the heading.")
attr(:posts, :list,
default: @posts,
doc:
"One to three `%{category, title, date}`. A title wraps onto up to three lines of about 15 characters."
)
attr(:featured, :integer,
default: 0,
doc: "Index of the post marked in lime. A negative index marks none."
)
attr(:label, :string,
default: "A blog section with three article cards, the first one marked as the newest"
)
frame_attrs()
@spec sections_blog(map()) :: Phoenix.LiveView.Rendered.t()
def sections_blog(assigns) do
posts = Enum.take(assigns.posts, 3)
count = max(length(posts), 1)
width = Float.round((352 - (count - 1) * 8) / count, 1)
limit = trunc((width - 18) / 6.0)
cards =
posts
|> Enum.with_index()
|> Enum.map(fn {post, index} ->
%{
post: post,
index: index,
x: Float.round(24 + index * (width + 8), 1),
lines: post.title |> wrap(limit) |> Enum.take(3) |> Enum.with_index()
}
end)
rows = cards |> Enum.map(&length(&1.lines)) |> Enum.max(fn -> 1 end)
card_h = 113 + rows * 14
assigns =
assigns
|> assign(:cards, cards)
|> assign(:width, width)
|> assign(:rows, rows)
|> assign(:card_h, card_h)
|> assign(:height, 76 + card_h + 24)
~H"""
<.frame name="sections/blog" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 400 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height={@height - 1} rx="12" />
<text class="ehv-mono ehv-a-fade ehv-i-1" x="24" y="34" font-size="8.5">
{String.upcase(@eyebrow)}
</text>
<text class="ehv-t-strong ehv-a-fade ehv-i-2" x="24" y="58" font-size="16">{@heading}</text>
<g class="ehv-a-fade ehv-i-2">
<text class="ehv-t-2" x="362" y="57" font-size="11" text-anchor="end">{@link}</text>
<path
class="ehv-line-ink ehv-round ehv-sections-blog__arrow"
d="M367 53.5 h8 m-3 -3 l3 3 l-3 3"
/>
</g>
<g
:for={card <- @cards}
class={"ehv-sections-blog__post ehv-a-rise ehv-i-#{card.index + 3}"}
>
<rect
class="ehv-card-2 ehv-sections-blog__card"
x={card.x + 0.5}
y="76.5"
width={@width - 1}
height={@card_h - 1}
rx="10"
/>
<rect class="ehv-well" x={card.x + 6.5} y="82.5" width={@width - 13} height="55" rx="6" />
<.cover
kind={rem(card.index, 3)}
x={card.x + 6}
w={@width - 12}
featured={card.index == @featured}
index={card.index}
/>
<text
class={["ehv-mono", if(card.index == @featured, do: "ehv-t-lime", else: "ehv-t-2")]}
x={card.x + 10}
y="155"
font-size="7.5"
>
{String.upcase(card.post.category)}
</text>
<text
:for={{line, row} <- card.lines}
x={card.x + 10}
y={172 + row * 14}
font-size="11.5"
>
{line}
</text>
<text class="ehv-mono" x={card.x + 10} y={177 + @rows * 14} font-size="7.5">
{String.upcase(card.post.date)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
attr(:kind, :integer, required: true)
attr(:x, :any, required: true)
attr(:w, :any, required: true)
attr(:featured, :boolean, required: true)
attr(:index, :integer, required: true)
# An abstract cover inside the 56 high well that starts at y 82: rising
# bars, rings or three linked steps. The featured post gets the lime detail.
defp cover(%{kind: 0} = assigns) do
heights = [12, 20, 16, 27, 23, 34]
left = assigns.x + (assigns.w - 73) / 2
bars =
heights
|> Enum.with_index()
|> Enum.map(fn {h, i} -> %{x: Float.round(left + i * 13, 1), y: 130 - h, h: h, i: i} end)
assigns = assign(assigns, :bars, bars)
~H"""
<g>
<line class="ehv-line-soft" x1={@x + 8} x2={@x + @w - 8} y1="130.5" y2="130.5" />
<rect
:for={bar <- @bars}
class={[
"ehv-a-grow-y ehv-i-#{min(@index + bar.i + 4, 24)}",
if(@featured and bar.i == 5, do: "ehv-fill-lime", else: "ehv-fill-strong")
]}
x={bar.x}
y={bar.y}
width="8"
height={bar.h}
rx="1.5"
/>
</g>
"""
end
defp cover(%{kind: 1} = assigns) do
assigns = assign(assigns, :cx, Float.round(assigns.x + assigns.w / 2, 1))
~H"""
<g class={"ehv-a-fade ehv-i-#{min(@index + 5, 24)}"}>
<circle :for={r <- [22, 15, 8]} class="ehv-line" cx={@cx} cy="110" r={r} />
<line class="ehv-line-soft" x1={@x + 8} x2={@x + @w - 8} y1="110.5" y2="110.5" />
<circle class={if @featured, do: "ehv-fill-lime", else: "ehv-fill-2"} cx={@cx} cy="110" r="3" />
</g>
"""
end
defp cover(assigns) do
step = (assigns.w - 44) / 2
nodes =
Enum.map(0..2, fn i ->
%{x: Float.round(assigns.x + 16 + i * step, 1), y: Enum.at([116, 98, 107], i), i: i}
end)
path =
nodes
|> Enum.map_join(" ", fn node -> "#{node.x + 6},#{node.y + 6}" end)
assigns = assigns |> assign(:nodes, nodes) |> assign(:path, path)
~H"""
<g class={"ehv-a-fade ehv-i-#{min(@index + 5, 24)}"}>
<polyline class="ehv-line" points={@path} />
<rect
:for={node <- @nodes}
class={if @featured and node.i == 2, do: "ehv-fill-lime", else: "ehv-node-soft"}
x={node.x}
y={node.y}
width="12"
height="12"
rx="3.5"
/>
</g>
"""
end
defp wrap(text, limit) do
text
|> String.split()
|> Enum.reduce([], fn
word, [] ->
[word]
word, [line | rest] ->
if String.length(line) + 1 + String.length(word) <= limit,
do: ["#{line} #{word}" | rest],
else: [word, line | rest]
end)
|> Enum.reverse()
end
end
assets/css/sections/blog.css
/* sections/blog */
.ehv-sections-blog { --ehv-max-w: 440px; }
.ehv--hover .ehv-sections-blog__card { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-sections-blog__post:hover .ehv-sections-blog__card { stroke: var(--ehv-lime); }
.ehv--hover .ehv-sections-blog__arrow { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-sections-blog__arrow { transform: translateX(2px); }