empowered.house ↗

sections/blog.ex

Blog

A blog index section with three article cards, each with an abstract cover, a category, a title and a date.

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

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

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