empowered.house ↗

sections/cta.ex

Call to action

A call-to-action section with an eyebrow, a heading, one line of copy and two buttons.

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

<div class="h-96 rounded-xl border border-border">
  <.sections_cta animated />
</div>
default
isometric
left aligned · custom copy
hover · no glow · one button
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Get started"
heading string "Run your operations on one platform" Wraps onto a second line after about 40 characters.
copy string "Orders, approvals and month-end close in one pla" <> ... One line, up to about 56 characters.
primary string "Book a walkthrough" The lime button.
secondary any "See how it works" The outlined button. Pass `nil` to leave it out.
align "center" | "left" "center"
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/cta.ex
cta.ex
defmodule EHVisuals.Sections.Cta do
  @moduledoc """
  A call-to-action section: an eyebrow, a heading, one line of copy and two buttons.
  """

  use EHVisuals.Composition,
    name: "sections/cta",
    title: "Call to action",
    description:
      "A call-to-action section with an eyebrow, a heading, one line of copy and two buttons."

  attr(:eyebrow, :string, default: "Get started")

  attr(:heading, :string,
    default: "Run your operations on one platform",
    doc: "Wraps onto a second line after about 40 characters."
  )

  attr(:copy, :string,
    default: "Orders, approvals and month-end close in one place.",
    doc: "One line, up to about 56 characters."
  )

  attr(:primary, :string, default: "Book a walkthrough", doc: "The lime button.")

  attr(:secondary, :any,
    default: "See how it works",
    doc: "The outlined button. Pass `nil` to leave it out."
  )

  attr(:align, :string, default: "center", values: ~w(center left))

  attr(:label, :string,
    default: "A call-to-action section with a heading, one line of copy and two buttons"
  )

  frame_attrs()

  @spec sections_cta(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_cta(assigns) do
    lines = assigns.heading |> wrap(40) |> Enum.take(2)
    copy_y = 82 + (length(lines) - 1) * 20 + 22
    buttons_y = copy_y + 20
    height = buttons_y + 26 + 48

    primary_w = Float.round(42 + String.length(assigns.primary) * 5.5, 1)

    secondary_w =
      if assigns.secondary,
        do: Float.round(28 + String.length(assigns.secondary) * 5.6, 1),
        else: 0

    total = primary_w + if(assigns.secondary, do: 8 + secondary_w, else: 0)
    centered = assigns.align == "center"
    start = if centered, do: Float.round(200 - total / 2, 1), else: 24

    assigns =
      assigns
      |> assign(:lines, Enum.with_index(lines))
      |> assign(:copy_y, copy_y)
      |> assign(:buttons_y, buttons_y)
      |> assign(:height, height)
      |> assign(:text_x, if(centered, do: 200, else: 24))
      |> assign(:anchor, if(centered, do: "middle", else: "start"))
      |> assign(:primary_x, start)
      |> assign(:primary_w, primary_w)
      |> assign(:secondary_x, start + primary_w + 8)
      |> assign(:secondary_w, secondary_w)
      |> assign(:arcs, arcs(centered, height))

    ~H"""
    <.frame name="sections/cta" 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" />
          <path
            :for={{d, index} <- @arcs}
            class={"ehv-line-soft ehv-a-draw ehv-i-#{index + 1}"}
            d={d}
            pathLength="1"
          />
          <text
            class="ehv-mono ehv-a-fade ehv-i-1"
            x={@text_x}
            y="56"
            font-size="8.5"
            text-anchor={@anchor}
          >
            {String.upcase(@eyebrow)}
          </text>
          <text
            :for={{line, index} <- @lines}
            class="ehv-t-strong ehv-a-fade ehv-i-2"
            x={@text_x}
            y={82 + index * 20}
            font-size="16"
            text-anchor={@anchor}
          >
            {line}
          </text>
          <text
            class="ehv-t-2 ehv-a-fade ehv-i-3"
            x={@text_x}
            y={@copy_y}
            font-size="11.5"
            text-anchor={@anchor}
          >
            {@copy}
          </text>
          <g class="ehv-a-rise ehv-i-4">
            <rect
              class="ehv-fill-lime"
              x={@primary_x}
              y={@buttons_y}
              width={@primary_w}
              height="26"
              rx="6"
            />
            <text
              class="ehv-t-strong ehv-t-on-lime"
              x={@primary_x + 14}
              y={@buttons_y + 17}
              font-size="10.5"
            >
              {@primary}
            </text>
            <path
              class="ehv-sections-cta__arrow ehv-round"
              d={"M#{@primary_x + @primary_w - 22} #{@buttons_y + 13} h8 m-3 -3 l3 3 l-3 3"}
            />
          </g>
          <g :if={@secondary} class="ehv-a-rise ehv-i-5">
            <rect
              class="ehv-line"
              x={@secondary_x + 0.5}
              y={@buttons_y + 0.5}
              width={@secondary_w - 1}
              height="25"
              rx="6"
            />
            <text
              class="ehv-t-strong"
              x={@secondary_x + @secondary_w / 2}
              y={@buttons_y + 17}
              font-size="10.5"
              text-anchor="middle"
            >
              {@secondary}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Quarter circles in two corners of the card, as {path, order}. The centred
  # layout keeps them away from the copy; the left one moves both to the right.
  defp arcs(centered, height) do
    radii = [28, 52, 76]

    corners =
      if centered,
        do: [{0.5, height - 0.5, 1, -1}, {399.5, 0.5, -1, 1}],
        else: [{399.5, height - 0.5, -1, -1}, {399.5, 0.5, -1, 1}]

    paths =
      for {cx, cy, dx, dy} <- corners, r <- radii do
        sweep = if dx * dy < 0, do: 0, else: 1
        "M#{cx + dx * r} #{cy} A#{r} #{r} 0 0 #{sweep} #{cx} #{cy + dy * r}"
      end

    Enum.with_index(paths)
  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/cta.css
cta.css
/* sections/cta */
.ehv-sections-cta { --ehv-max-w: 440px; }

.ehv-sections-cta__arrow {
  fill: none;
  stroke: var(--ehv-on-lime);
  stroke-width: 1.25;
  transform-box: fill-box;
}

.ehv--hover .ehv-sections-cta__arrow { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-sections-cta__arrow { transform: translateX(3px); }