empowered.house ↗

sections/testimonials.ex

Testimonials

A testimonials section with quote cards, each a quote in a few lines and its author's initials, name and company.

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

<div class="h-96 rounded-xl border border-border">
  <.sections_testimonials animated />
</div>
default
isometric
two quotes · first one featured
hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Customers"
title string "What operations teams say" Up to 44 characters.
quotes list [%{name: "Marta Nowak", lines: 3, company: "Northwind"}, %{name: "Jan Kowalski", ...}, ...] One to three `%{name, company}` with an optional `lines`, the length of the quote from two to four. With three cards: a name of up to 14 characters, a company of 13.
featured integer 1 Index of the quote picked out in lime. A negative index picks 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/testimonials.ex
testimonials.ex
defmodule EHVisuals.Sections.Testimonials do
  @moduledoc """
  A testimonials section: quote cards with the quote in a few lines and the
  author's avatar, name and company, one of them picked out in lime.
  """

  use EHVisuals.Composition,
    name: "sections/testimonials",
    title: "Testimonials",
    description:
      "A testimonials section with quote cards, each a quote in a few lines and its author's initials, name and company."

  @quotes [
    %{name: "Marta Nowak", company: "Northwind", lines: 3},
    %{name: "Jan Kowalski", company: "Acme Tools", lines: 4},
    %{name: "Ewa Lis", company: "Globex", lines: 3}
  ]

  # How far each line of a quote runs, as a share of the card's inner width.
  @reach [1.0, 0.9, 0.96, 0.82]

  attr(:eyebrow, :string, default: "Customers")
  attr(:title, :string, default: "What operations teams say", doc: "Up to 44 characters.")

  attr(:quotes, :list,
    default: @quotes,
    doc:
      "One to three `%{name, company}` with an optional `lines`, the length of the quote from two to four. With three cards: a name of up to 14 characters, a company of 13."
  )

  attr(:featured, :integer,
    default: 1,
    doc: "Index of the quote picked out in lime. A negative index picks none."
  )

  attr(:label, :string, default: "Three customer quotes, each signed with a name and a company")
  frame_attrs()

  @spec sections_testimonials(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_testimonials(assigns) do
    quotes = Enum.take(assigns.quotes, 3)
    count = max(length(quotes), 1)
    width = Float.round((432 - (count - 1) * 10) / count, 1)
    counts = Enum.map(quotes, &(&1 |> Map.get(:lines, 3) |> max(2) |> min(4)))
    card_h = 48 + Enum.max(counts, fn -> 3 end) * 12 + 10 + 22 + 14

    cards =
      quotes
      |> Enum.zip(counts)
      |> Enum.with_index()
      |> Enum.map(fn {{item, lines}, index} ->
        x = Float.round(24 + index * (width + 10), 1)

        %{
          x: x,
          name: item.name,
          company: item.company,
          initials: initials(item.name),
          lines: skeleton(lines, width - 28),
          on: index == assigns.featured,
          index: index
        }
      end)

    assigns =
      assigns
      |> assign(:cards, cards)
      |> assign(:width, width)
      |> assign(:card_h, card_h)
      |> assign(:author_y, 76 + card_h - 25)
      |> assign(:height, 76 + card_h + 24)

    ~H"""
    <.frame name="sections/testimonials" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 480 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="479" height={@height - 1} rx="12" />
          <text
            class="ehv-mono ehv-a-fade ehv-i-1"
            x="240"
            y="34"
            font-size="8.5"
            text-anchor="middle"
          >
            {String.upcase(@eyebrow)}
          </text>
          <text
            class="ehv-t-strong ehv-a-fade ehv-i-2"
            x="240"
            y="58"
            font-size="16"
            text-anchor="middle"
          >
            {@title}
          </text>
          <g :for={card <- @cards} class={"ehv-a-rise ehv-i-#{card.index + 3}"}>
            <g class={[card.on && "ehv-sections-testimonials__featured"]}>
              <rect
                class={if card.on, do: "ehv-sections-testimonials__card--on", else: "ehv-card-2"}
                x={card.x + 0.5}
                y="76.5"
                width={@width - 1}
                height={@card_h - 1}
                rx="10"
              />
              <path
                class={if card.on, do: "ehv-sections-testimonials__mark--on", else: "ehv-fill-4"}
                d={quote_mark(card.x + 14, 92)}
              />
              <rect
                :for={{line_w, row} <- card.lines}
                class={"ehv-fill-soft ehv-a-grow-x ehv-i-#{min(card.index + row + 5, 24)}"}
                x={card.x + 14}
                y={114 + row * 12}
                width={line_w}
                height="5"
                rx="2.5"
              />
              <circle class="ehv-card-3" cx={card.x + 25} cy={@author_y} r="11" />
              <text
                class="ehv-mono ehv-t-2"
                x={card.x + 25}
                y={@author_y + 2.6}
                font-size="7"
                text-anchor="middle"
              >
                {card.initials}
              </text>
              <text class="ehv-t-strong" x={card.x + 44} y={@author_y - 1.5} font-size="11">
                {card.name}
              </text>
              <text class="ehv-mono" x={card.x + 44} y={@author_y + 10} font-size="7.5">
                {String.upcase(card.company)}
              </text>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end

  # The quote as skeleton lines: full ones, then a shorter last line.
  defp skeleton(count, width) do
    @reach
    |> Enum.take(count - 1)
    |> Kernel.++([0.58])
    |> Enum.map(&Float.round(&1 * width, 1))
    |> Enum.with_index()
  end

  # An opening quotation mark, drawn as two commas with their tails up.
  defp quote_mark(x, y) do
    Enum.map_join([0, 10], " ", fn dx ->
      "M#{x + dx} #{y + 10} v-4.5 a6 6 0 0 1 6 -6 v2.6 a3.4 3.4 0 0 0 -3 3.4 h3 v4.5 z"
    end)
  end
end
assets/css/sections/testimonials.css
testimonials.css
/* sections/testimonials */
.ehv-sections-testimonials { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; }

.ehv .ehv-sections-testimonials__card--on {
  fill: var(--ehv-surface-2);
  stroke: var(--ehv-lime);
  stroke-width: 1;
}

/* The featured quote steps forward. */
.ehv--hover .ehv-sections-testimonials__featured { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-sections-testimonials__featured { transform: translateY(-4px); }

/* The line colour of lime, so the mark keeps its contrast on the light surface. */
.ehv .ehv-sections-testimonials__mark--on { fill: var(--ehv-lime); }