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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.sections_pricing animated />
</div>
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
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
/* 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); }