empowered.house ↗

sections/pricing.ex

Pricing

A pricing section with plan cards, each with a price and a few checked features, and one plan picked out in lime.

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

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

Props

Prop Type Default What it does
eyebrow string "Pricing"
title string "Priced by process, not by seat" Up to 44 characters.
plans list [%{name: "Starter", features: ["One process", "Two agents", "Email support"], ...}, ...] One to three `%{name, price, features}`, with an optional `period` and `action` of their own. Up to five features of 17 characters, a name of 9.
picked integer 1 Index of the plan picked out in lime. A negative index picks none.
badge any "Popular" The pill on the picked plan, up to 9 characters. `nil` hides it.
period string "per month" Under each price.
action string "Choose plan" The button of each plan.
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/pricing.ex
pricing.ex
defmodule EHVisuals.Sections.Pricing do
  @moduledoc """
  A pricing section: plan cards with a name, a price, a few features with
  checks and a button, and one plan picked out in lime.
  """

  use EHVisuals.Composition,
    name: "sections/pricing",
    title: "Pricing",
    description:
      "A pricing section with plan cards, each with a price and a few checked features, and one plan picked out in lime."

  @plans [
    %{name: "Starter", price: "€490", features: ["One process", "Two agents", "Email support"]},
    %{name: "Team", price: "€1,900", features: ["Five processes", "Ten agents", "Approvals"]},
    %{
      name: "Company",
      price: "Custom",
      period: "billed yearly",
      features: ["Every process", "Own cloud region", "Named engineer"],
      action: "Talk to us"
    }
  ]

  attr(:eyebrow, :string, default: "Pricing")
  attr(:title, :string, default: "Priced by process, not by seat", doc: "Up to 44 characters.")

  attr(:plans, :list,
    default: @plans,
    doc:
      "One to three `%{name, price, features}`, with an optional `period` and `action` of their own. Up to five features of 17 characters, a name of 9."
  )

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

  attr(:badge, :any,
    default: "Popular",
    doc: "The pill on the picked plan, up to 9 characters. `nil` hides it."
  )

  attr(:period, :string, default: "per month", doc: "Under each price.")
  attr(:action, :string, default: "Choose plan", doc: "The button of each plan.")

  attr(:label, :string, default: "Three pricing plans with the middle one picked out")
  frame_attrs()

  @spec sections_pricing(map()) :: Phoenix.LiveView.Rendered.t()
  def sections_pricing(assigns) do
    plans = Enum.take(assigns.plans, 3)
    count = max(length(plans), 1)
    width = Float.round((432 - (count - 1) * 10) / count, 1)

    rows =
      plans |> Enum.map(&length(Enum.take(&1.features, 5))) |> Enum.max(fn -> 0 end)

    button_y = 76 + 98 + rows * 18
    card_h = button_y + 26 + 14 - 76

    cards =
      plans
      |> Enum.with_index()
      |> Enum.map(fn {plan, index} ->
        %{
          x: Float.round(24 + index * (width + 10), 1),
          name: plan.name,
          price: plan.price,
          period: Map.get(plan, :period, assigns.period),
          action: Map.get(plan, :action, assigns.action),
          features: plan.features |> Enum.take(5) |> Enum.with_index(),
          picked: index == assigns.picked,
          index: index
        }
      end)

    assigns =
      assigns
      |> assign(:cards, cards)
      |> assign(:width, width)
      |> assign(:card_h, card_h)
      |> assign(:button_y, button_y)
      |> assign(
        :badge_w,
        if(assigns.badge, do: Float.round(12 + String.length(assigns.badge) * 4.9, 1), else: 0)
      )
      |> assign(:height, 76 + card_h + 24)

    ~H"""
    <.frame name="sections/pricing" 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.picked && "ehv-sections-pricing__picked"]}>
              <rect
                class={if card.picked, do: "ehv-sections-pricing__plan--picked", else: "ehv-card-2"}
                x={card.x + 0.5}
                y="76.5"
                width={@width - 1}
                height={@card_h - 1}
                rx="10"
              />
              <text class="ehv-t-strong" x={card.x + 14} y="100" font-size="12">{card.name}</text>
              <g :if={card.picked && @badge}>
                <rect
                  class="ehv-fill-lime-soft"
                  x={card.x + @width - 10 - @badge_w}
                  y="88"
                  width={@badge_w}
                  height="16"
                  rx="5"
                />
                <text
                  class="ehv-mono ehv-t-lime"
                  x={card.x + @width - 10 - @badge_w / 2}
                  y="98.6"
                  font-size="7"
                  text-anchor="middle"
                >
                  {String.upcase(@badge)}
                </text>
              </g>
              <text class="ehv-t-display" x={card.x + 14} y="130" font-size="22">{card.price}</text>
              <text :if={card.period} class="ehv-mono" x={card.x + 14} y="145" font-size="7.5">
                {String.upcase(card.period)}
              </text>
              <line
                class="ehv-line-faint"
                x1={card.x + 14}
                x2={card.x + @width - 14}
                y1="156.5"
                y2="156.5"
              />
              <g :for={{feature, row} <- card.features}>
                <path
                  class={[
                    "ehv-round ehv-a-draw",
                    "ehv-i-#{min(card.index + row + 6, 24)}",
                    if(card.picked, do: "ehv-line-lime", else: "ehv-line-ink")
                  ]}
                  d={"M#{card.x + 14} #{173.5 + row * 18} l2.6 2.6 l4.6 -5.2"}
                  pathLength="1"
                />
                <text class="ehv-t-2" x={card.x + 28} y={177 + row * 18} font-size="10.5">
                  {feature}
                </text>
              </g>
              <rect
                class={if card.picked, do: "ehv-fill-lime", else: "ehv-line"}
                x={card.x + 14.5}
                y={@button_y + 0.5}
                width={@width - 29}
                height="25"
                rx="7"
              />
              <text
                class={["ehv-t-strong", card.picked && "ehv-t-on-lime"]}
                x={card.x + @width / 2}
                y={@button_y + 17}
                font-size="11"
                text-anchor="middle"
              >
                {card.action}
              </text>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/sections/pricing.css
pricing.css
/* sections/pricing */
.ehv-sections-pricing { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; }

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

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