empowered.house ↗

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.

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

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

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