empowered.house ↗

avatars/stack.ex

Avatar stack

An overlapping stack of avatars with a badge for the rest and a short caption, for who is involved.

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

<div class="h-96 rounded-xl border border-border">
  <.avatars_stack animated />
</div>
default
isometric
three people · custom copy
no badge · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Approvers"
people list ["Marta Nowak", "Jan Kowalski", "Ewa Lis", "Piotr Zielinski", "Olga Sowa"] Up to five names, shown as initials.
extra integer 12 How many more people the badge stands for. 0 hides the badge.
caption string "Marta, Jan and 15 others" The line under the stack. Up to 40 characters.
meta any "Sign off the month-end close" The mono line under the caption. `nil` hides it.
highlight any 0 Index of the avatar filled with lime, from 0. `nil` leaves all of them neutral.
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/avatars/stack.ex
stack.ex
defmodule EHVisuals.Avatars.Stack do
  @moduledoc """
  An overlapping stack of avatars with a badge for the rest and a caption.
  """

  use EHVisuals.Composition,
    name: "avatars/stack",
    title: "Avatar stack",
    description:
      "An overlapping stack of avatars with a badge for the rest and a short caption, for who is involved."

  attr(:eyebrow, :string, default: "Approvers")

  attr(:people, :list,
    default: ["Marta Nowak", "Jan Kowalski", "Ewa Lis", "Piotr Zielinski", "Olga Sowa"],
    doc: "Up to five names, shown as initials."
  )

  attr(:extra, :integer,
    default: 12,
    doc: "How many more people the badge stands for. 0 hides the badge."
  )

  attr(:caption, :string,
    default: "Marta, Jan and 15 others",
    doc: "The line under the stack. Up to 40 characters."
  )

  attr(:meta, :any,
    default: "Sign off the month-end close",
    doc: "The mono line under the caption. `nil` hides it."
  )

  attr(:highlight, :any,
    default: 0,
    doc: "Index of the avatar filled with lime, from 0. `nil` leaves all of them neutral."
  )

  attr(:label, :string, default: "Five overlapping avatars and a badge for twelve more people")
  frame_attrs()

  @spec avatars_stack(map()) :: Phoenix.LiveView.Rendered.t()
  def avatars_stack(assigns) do
    avatars =
      for {name, index} <- assigns.people |> Enum.take(5) |> Enum.with_index() do
        %{
          initials: initials(name),
          index: index,
          cx: 44 + index * 38,
          on: index == assigns.highlight
        }
      end

    assigns =
      assigns
      |> assign(:avatars, avatars)
      |> assign(:badge_x, 44 + length(avatars) * 38)
      |> assign(:badge_slot, length(avatars))
      |> assign(:height, if(assigns.meta, do: 154, else: 140))

    ~H"""
    <.frame name="avatars/stack" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 320 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="319" height={@height - 1} rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
            {String.upcase(@eyebrow)}
          </text>
          <g :for={avatar <- @avatars} class={"ehv-a-slide ehv-i-#{avatar.index + 2}"}>
            <g class={"ehv-avatars-stack__slot ehv-avatars-stack__slot--#{avatar.index}"}>
              <circle class="ehv-avatars-stack__gap" cx={avatar.cx} cy="76" r="27" />
              <circle
                class={if avatar.on, do: "ehv-fill-lime", else: "ehv-card-3"}
                cx={avatar.cx}
                cy="76"
                r="24"
              />
              <text
                class={["ehv-mono", if(avatar.on, do: "ehv-t-on-lime", else: "ehv-t-2")]}
                x={avatar.cx}
                y="79.5"
                font-size="10"
                text-anchor="middle"
              >
                {avatar.initials}
              </text>
            </g>
          </g>
          <g :if={@extra > 0} class={"ehv-a-slide ehv-i-#{@badge_slot + 2}"}>
            <g class={"ehv-avatars-stack__slot ehv-avatars-stack__slot--#{@badge_slot}"}>
              <circle class="ehv-avatars-stack__gap" cx={@badge_x} cy="76" r="27" />
              <circle class="ehv-well" cx={@badge_x} cy="76" r="24" />
              <text
                class="ehv-mono ehv-t-ink"
                x={@badge_x}
                y="79.5"
                font-size="10"
                text-anchor="middle"
              >
                +{@extra}
              </text>
            </g>
          </g>
          <g class="ehv-a-fade ehv-i-8">
            <text x="20" y="124" font-size="12.5">{@caption}</text>
            <text :if={@meta} class="ehv-mono" x="20" y="139" font-size="8.5">
              {String.upcase(@meta)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end
end
assets/css/avatars/stack.css
stack.css
/* avatars/stack */
.ehv-avatars-stack { --ehv-max-w: 350px; }

/* The ring in the card's own colour that separates one avatar from the next. */
.ehv-avatars-stack__gap { fill: var(--ehv-surface-1); }

/* Hover fans the stack out: each avatar moves a little further than the last. */
.ehv--hover .ehv-avatars-stack__slot { transition: transform 260ms var(--ehv-ease); }
.ehv--hover:hover .ehv-avatars-stack__slot--0 { transform: translateX(0); }
.ehv--hover:hover .ehv-avatars-stack__slot--1 { transform: translateX(3px); }
.ehv--hover:hover .ehv-avatars-stack__slot--2 { transform: translateX(6px); }
.ehv--hover:hover .ehv-avatars-stack__slot--3 { transform: translateX(9px); }
.ehv--hover:hover .ehv-avatars-stack__slot--4 { transform: translateX(12px); }
.ehv--hover:hover .ehv-avatars-stack__slot--5 { transform: translateX(15px); }