empowered.house ↗

security/fingerprint.ex

Fingerprint

Fingerprint ridges drawn as concentric arcs with a scan line and a verified state, for sign-in and approval moments.

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

<div class="h-96 rounded-xl border border-border">
  <.security_fingerprint animated />
</div>
default
isometric
scanning · custom copy
failed · hover · no glow
light surface
six ridges · fade out · still

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
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
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); }