sections/blog-post.ex
Blog post
A blog post layout with a category and date, a title, an author line and text lines around one pull quote.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.sections_blog_post animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
category |
string | "Finance" |
|
date |
string | "12 Nov 2026" |
|
title |
string | "Month-end close in three days" |
Wraps onto a second line after about 40 characters. |
author |
string | "Marta Nowak" |
|
read_time |
string | "6 min read" |
|
quote |
string | "The close got shorter when nobody had to chase a" <> ... |
The pull quote. Wraps onto up to three lines of about 52 characters. |
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-post.ex
defmodule EHVisuals.Sections.BlogPost do
@moduledoc """
A blog post layout: category and date, a title, an author line, and the
content as text lines around one pull quote.
"""
use EHVisuals.Composition,
name: "sections/blog-post",
title: "Blog post",
description:
"A blog post layout with a category and date, a title, an author line and text lines around one pull quote."
# Widths of the text lines before and after the quote.
@lead [352, 334, 218]
@tail [352, 306, 340, 164]
attr(:category, :string, default: "Finance")
attr(:date, :string, default: "12 Nov 2026")
attr(:title, :string,
default: "Month-end close in three days",
doc: "Wraps onto a second line after about 40 characters."
)
attr(:author, :string, default: "Marta Nowak")
attr(:read_time, :string, default: "6 min read")
attr(:quote, :string,
default: "The close got shorter when nobody had to chase a missing accrual.",
doc: "The pull quote. Wraps onto up to three lines of about 52 characters."
)
attr(:label, :string,
default: "A blog post with its category, date, title and author, and a pull quote in the text"
)
frame_attrs()
@spec sections_blog_post(map()) :: Phoenix.LiveView.Rendered.t()
def sections_blog_post(assigns) do
title = assigns.title |> wrap(40) |> Enum.take(2)
quoted = assigns.quote |> wrap(52) |> Enum.take(3)
author_y = 60 + (length(title) - 1) * 20 + 26
rule_y = author_y + 20
before_y = rule_y + 18
quote_y = before_y + length(@lead) * 13 + 9
quote_h = length(quoted) * 18 + 6
after_y = quote_y + quote_h + 16
assigns =
assigns
|> assign(:title_lines, Enum.with_index(title))
|> assign(:quote_lines, Enum.with_index(quoted))
|> assign(:author_y, author_y)
|> assign(:rule_y, rule_y)
|> assign(:lead_lines, lines(@lead, before_y, 4))
|> assign(:quote_y, quote_y)
|> assign(:quote_h, quote_h)
|> assign(:tail_lines, lines(@tail, after_y, 10))
|> assign(:height, after_y + (length(@tail) - 1) * 13 + 6 + 24)
~H"""
<.frame name="sections/blog-post" 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">
<tspan class="ehv-t-2">{String.upcase(@category)}</tspan>
<tspan dx="1">·</tspan>
<tspan dx="1">{String.upcase(@date)}</tspan>
</text>
<text
:for={{line, index} <- @title_lines}
class="ehv-t-strong ehv-a-fade ehv-i-2"
x="24"
y={60 + index * 20}
font-size="16"
>
{line}
</text>
<g class="ehv-a-fade ehv-i-3">
<circle class="ehv-card-3" cx="34" cy={@author_y} r="10" />
<text
class="ehv-mono ehv-t-2"
x="34"
y={@author_y + 2.6}
font-size="7"
text-anchor="middle"
>
{initials(@author)}
</text>
<text x="52" y={@author_y + 4} font-size="11.5">
<tspan>{@author}</tspan>
<tspan class="ehv-t-3" dx="5">· {@read_time}</tspan>
</text>
</g>
<line class="ehv-line-faint" x1="24" x2="376" y1={@rule_y + 0.5} y2={@rule_y + 0.5} />
<rect
:for={line <- @lead_lines ++ @tail_lines}
class={"ehv-fill-soft ehv-a-grow-x ehv-i-#{line.i}"}
x="24"
y={line.y}
width={line.w}
height="6"
rx="3"
/>
<rect
class="ehv-sections-blog-post__bar ehv-a-grow-y ehv-i-7"
x="24"
y={@quote_y}
width="2.5"
height={@quote_h}
rx="1.25"
/>
<text
:for={{line, index} <- @quote_lines}
class={"ehv-a-fade ehv-i-#{8 + index}"}
x="40"
y={@quote_y + 15 + index * 18}
font-size="13"
>
{line}
</text>
</g>
</svg>
</.frame>
"""
end
defp lines(widths, y, first) do
widths
|> Enum.with_index()
|> Enum.map(fn {w, index} -> %{w: w, y: y + index * 13, i: first + index} end)
end
defp initials(name) do
name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
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-post.css
/* sections/blog-post */
.ehv-sections-blog-post { --ehv-max-w: 440px; }
/* The rule beside the pull quote is a line, so it takes the line lime. */
.ehv .ehv-sections-blog-post__bar { fill: var(--ehv-lime); }