security/fingerprint.ex
Fingerprint
Fingerprint ridges drawn as concentric arcs with a scan line and a verified state, for sign-in and approval moments.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.security_fingerprint animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Identity check" |
|
name |
string | "Marta Nowak" |
|
detail |
string | "Approving payment run PR-118" |
Up to 34 characters. |
state |
"scanning" | "verified" | "failed" | "verified" |
`scanning` puts the scan line across the print, `verified` and `failed` show the result. |
status |
any | nil |
The text of the pill. Left as `nil`, it is written from the state. |
ridges |
integer | 9 |
How many ridges the print has, 5 to 10. |
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/security/fingerprint.ex
defmodule EHVisuals.Security.Fingerprint do
@moduledoc """
A fingerprint drawn as concentric ridges under a scan line, with the result
of the check.
"""
use EHVisuals.Composition,
name: "security/fingerprint",
title: "Fingerprint",
description:
"Fingerprint ridges drawn as concentric arcs with a scan line and a verified state, for sign-in and approval moments."
# The print: the centre of the arches, the radius of the first and of the
# last ridge, and how much taller than wide the arches are.
@cx 150
@cy 150
@first 8
@reach 76
@squash 1.1
# Where each ridge breaks, as fractions of its length from the left foot.
@breaks [
[],
[{0.62, 0.7}],
[{0.18, 0.25}],
[{0.44, 0.5}, {0.86, 0.93}],
[{0.08, 0.14}, {0.66, 0.72}],
[{0.3, 0.36}],
[{0.55, 0.61}, {0.9, 0.96}],
[{0.12, 0.18}, {0.4, 0.45}],
[{0.72, 0.78}],
[{0.25, 0.3}, {0.6, 0.65}]
]
attr(:caption, :string, default: "Identity check")
attr(:name, :string, default: "Marta Nowak")
attr(:detail, :string, default: "Approving payment run PR-118", doc: "Up to 34 characters.")
attr(:state, :string,
default: "verified",
values: ~w(scanning verified failed),
doc:
"`scanning` puts the scan line across the print, `verified` and `failed` show the result."
)
attr(:status, :any,
default: nil,
doc: "The text of the pill. Left as `nil`, it is written from the state."
)
attr(:ridges, :integer, default: 9, doc: "How many ridges the print has, 5 to 10.")
attr(:label, :string, default: "A fingerprint that has been scanned and verified")
frame_attrs()
@spec security_fingerprint(map()) :: Phoenix.LiveView.Rendered.t()
def security_fingerprint(assigns) do
count = assigns.ridges |> max(5) |> min(10)
step = (@reach - @first) / (count - 1)
status = assigns.status || status(assigns.state)
ridges =
for index <- 0..(count - 1) do
r = Float.round(@first + index * step, 1)
leg = 64 * :math.sqrt(1 - :math.pow(r / (@reach + 7), 2)) + 6
%{index: index, d: ridge(r, leg, Enum.at(@breaks, index))}
end
assigns =
assigns
|> assign(:cx, @cx)
|> assign(:cy, @cy)
|> assign(:items, ridges)
|> assign(:scan_y, if(assigns.state == "scanning", do: 152, else: 231))
|> assign(:status, status)
|> assign(
:pill_w,
Float.round(
if(assigns.state == "scanning", do: 28, else: 18) + String.length(status) * 5.8,
1
)
)
~H"""
<.frame name="security/fingerprint" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 300 312">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="299" height="311" rx="12" />
<text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-a-fade ehv-i-12">
<rect
class={"ehv-security-fingerprint__pill--#{@state}"}
x={280 - @pill_w}
y="17"
width={@pill_w}
height="20"
rx="6"
/>
<circle
:if={@state == "scanning"}
class="ehv-fill-lime ehv-a-pulse"
cx={280 - @pill_w + 11}
cy="27"
r="2.5"
/>
<text
class={["ehv-mono", "ehv-security-fingerprint__pill-text--#{@state}"]}
x="271"
y="30.2"
font-size="8.5"
text-anchor="end"
>
{String.upcase(@status)}
</text>
</g>
<g class="ehv-a-fade ehv-i-1">
<path
class="ehv-line ehv-round"
d="M58 66v-12h12M230 54h12v12M242 220v12h-12M70 232h-12v-12"
/>
</g>
<line
class={"ehv-security-fingerprint__ridge ehv-security-fingerprint__ridge--#{@state} ehv-a-draw ehv-i-1"}
x1={@cx}
x2={@cx}
y1={@cy + 6}
y2={@cy + 46}
pathLength="1"
/>
<path
:for={ridge <- @items}
class={"ehv-security-fingerprint__ridge ehv-security-fingerprint__ridge--#{@state} ehv-a-draw ehv-i-#{ridge.index + 1}"}
d={ridge.d}
pathLength="1"
/>
<g class={"ehv-security-fingerprint__scan ehv-security-fingerprint__scan--#{@state}"}>
<rect
class="ehv-security-fingerprint__beam"
x="58"
y={@scan_y - 12}
width="184"
height="12"
/>
<line class="ehv-security-fingerprint__line" x1="52" x2="248" y1={@scan_y} y2={@scan_y} />
</g>
<g :if={@state == "verified"} class="ehv-a-pop ehv-i-13">
<circle class="ehv-security-fingerprint__halo" cx="236" cy="226" r="18" />
<circle class="ehv-fill-lime" cx="236" cy="226" r="14" />
<path
class="ehv-security-fingerprint__check ehv-round ehv-a-draw ehv-i-15"
d="M229.5 226.5l4.5 4.5l8.5 -9.5"
pathLength="1"
/>
</g>
<g :if={@state == "failed"} class="ehv-a-pop ehv-i-13">
<circle class="ehv-security-fingerprint__halo" cx="236" cy="226" r="18" />
<circle class="ehv-fill-coral" cx="236" cy="226" r="14" />
<path class="ehv-security-fingerprint__check ehv-round" d="M231 221l10 10m0 -10l-10 10" />
</g>
<line class="ehv-line-faint" x1="20" x2="280" y1="258.5" y2="258.5" />
<g class="ehv-a-rise ehv-i-11">
<text class="ehv-t-strong" x="20" y="281" font-size="13">{@name}</text>
<text class="ehv-mono" x="20" y="296" font-size="8">{String.upcase(@detail)}</text>
</g>
</g>
</svg>
</.frame>
"""
end
# One ridge: a leg up the left, an arch over the top and a leg down the
# right, cut where the ridge breaks.
defp ridge(r, leg, breaks) do
arch = :math.pi() * r
total = 2 * leg + arch
breaks
|> pieces()
|> Enum.map_join(fn {from, to} -> piece(r, leg, arch, from * total, to * total) end)
end
# The drawn stretches between the breaks, as fractions.
defp pieces(breaks) do
{pieces, last} =
Enum.reduce(breaks, {[], 0.0}, fn {from, to}, {acc, at} -> {[{at, from} | acc], to} end)
Enum.reverse([{last, 1.0} | pieces])
end
defp piece(r, leg, arch, from, to) do
left = leg
right = leg + arch
{sx, sy} = at(r, leg, arch, from)
{ex, ey} = at(r, leg, arch, to)
ry = Float.round(r * @squash, 1)
start = "M#{sx} #{sy}"
up = if from < left and to > left, do: "L#{@cx - r} #{@cy}", else: ""
over =
cond do
to <= left or from >= right ->
""
to > right ->
"A#{r} #{ry} 0 0 1 #{@cx + r} #{@cy}"
true ->
"A#{r} #{ry} 0 0 1 #{ex} #{ey}"
end
down = if to <= left or to > right, do: "L#{ex} #{ey}", else: ""
start <> up <> over <> down
end
# The point at distance `u` along a ridge, measured from the left foot.
defp at(r, leg, arch, u) do
cond do
u <= leg ->
{@cx - r, Float.round(@cy + leg - u, 1)}
u >= leg + arch ->
{@cx + r, Float.round(@cy + u - leg - arch, 1)}
true ->
angle = (u - leg) / arch * :math.pi()
{
Float.round(@cx - r * :math.cos(angle), 1),
Float.round(@cy - r * @squash * :math.sin(angle), 1)
}
end
end
defp status("scanning"), do: "Scanning"
defp status("failed"), do: "No match"
defp status(_state), do: "Verified"
end
assets/css/security/fingerprint.css
/* security/fingerprint */
.ehv-security-fingerprint { --ehv-max-w: 330px; --ehv-step: 70ms; }
.ehv-security-fingerprint__ridge { fill: none; stroke-width: 2.2; stroke-linecap: round; }
.ehv-security-fingerprint__ridge--scanning { stroke: var(--ehv-ink-3); }
.ehv-security-fingerprint__ridge--verified { stroke: var(--ehv-ink-2); }
.ehv-security-fingerprint__ridge--failed { stroke: var(--ehv-ink-3); }
.ehv-security-fingerprint__line { fill: none; stroke: var(--ehv-lime); stroke-width: 1.5; stroke-linecap: round; }
.ehv-security-fingerprint__beam { fill: var(--ehv-lime-soft); }
.ehv-security-fingerprint__scan--failed .ehv-security-fingerprint__line { stroke: var(--ehv-coral); }
.ehv-security-fingerprint__scan--failed .ehv-security-fingerprint__beam { fill: var(--ehv-coral-soft); }
/* The ring of card colour that lifts the result badge off the ridges. */
.ehv-security-fingerprint__halo { fill: var(--ehv-surface-1); }
.ehv-security-fingerprint__check { fill: none; stroke: var(--ehv-on-lime); stroke-width: 2.25; }
.ehv-security-fingerprint__pill--scanning { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-security-fingerprint__pill--verified { fill: var(--ehv-lime-soft); }
.ehv-security-fingerprint__pill--failed { fill: var(--ehv-coral-soft); }
.ehv svg .ehv-mono.ehv-security-fingerprint__pill-text--scanning { fill: var(--ehv-ink-2); }
.ehv svg .ehv-mono.ehv-security-fingerprint__pill-text--verified { fill: var(--ehv-lime); }
.ehv svg .ehv-mono.ehv-security-fingerprint__pill-text--failed { fill: var(--ehv-coral); }
.ehv-security-fingerprint__scan { transform-box: view-box; }
/* While scanning, the line travels over the print and back. Once there is a
result it comes down from the top once and rests at the bottom. */
@keyframes ehv-security-fingerprint-sweep {
from { transform: translateY(-88px); }
to { transform: translateY(78px); }
}
@keyframes ehv-security-fingerprint-settle {
from { transform: translateY(-166px); opacity: 0; }
12% { opacity: 1; }
}
.ehv--animated .ehv-security-fingerprint__scan--scanning {
animation: ehv-security-fingerprint-sweep 2.2s ease-in-out infinite alternate;
}
.ehv--animated :is(.ehv-security-fingerprint__scan--verified, .ehv-security-fingerprint__scan--failed) {
animation: ehv-security-fingerprint-settle 1.5s var(--ehv-ease) backwards;
animation-delay: calc(var(--ehv-delay, 0ms) + 300ms);
}
.ehv--hover .ehv-security-fingerprint__ridge { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-security-fingerprint__ridge { stroke: var(--ehv-ink); }