avatars/grid.ex
Avatar grid
A team as a grid of initials, revealed one by one, with one person marked.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.avatars_grid animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
eyebrow |
string | "Operations team" |
|
people |
list | ["Marta Nowak", "Jan Kowalski", "Ewa Lis", "Piotr Zielinski", "Anna Maj", "Tomasz Wrona", ...] |
Up to 24 names. Each shows as its initials. |
columns |
integer | 5 |
Avatars per row, three to six. |
highlight |
any | 7 |
Index of the person marked in lime, from 0. `nil` marks nobody and drops the footer. |
note |
string | "On call today" |
What the marked person is doing, next to the name. Up to 22 characters. |
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/grid.ex
defmodule EHVisuals.Avatars.Grid do
@moduledoc """
A team as a grid of initials, revealed one by one, with one person marked.
"""
use EHVisuals.Composition,
name: "avatars/grid",
title: "Avatar grid",
description: "A team as a grid of initials, revealed one by one, with one person marked."
@people [
"Marta Nowak",
"Jan Kowalski",
"Ewa Lis",
"Piotr Zielinski",
"Anna Maj",
"Tomasz Wrona",
"Olga Sowa",
"Adam Krol",
"Kasia Duda",
"Marek Bak",
"Iga Stec",
"Pawel Rak",
"Zofia Kot",
"Leon Wilk",
"Nina Gil"
]
attr(:eyebrow, :string, default: "Operations team")
attr(:people, :list, default: @people, doc: "Up to 24 names. Each shows as its initials.")
attr(:columns, :integer, default: 5, doc: "Avatars per row, three to six.")
attr(:highlight, :any,
default: 7,
doc: "Index of the person marked in lime, from 0. `nil` marks nobody and drops the footer."
)
attr(:note, :string,
default: "On call today",
doc: "What the marked person is doing, next to the name. Up to 22 characters."
)
attr(:label, :string, default: "A team of fifteen people as initials, one of them on call")
frame_attrs()
@spec avatars_grid(map()) :: Phoenix.LiveView.Rendered.t()
def avatars_grid(assigns) do
columns = assigns.columns |> max(3) |> min(6)
people = Enum.take(assigns.people, 24)
cell = 300 / columns
radius = Float.round(min(cell / 2 - 6, 26.0), 1)
rows = max(ceil(length(people) / columns), 1)
bottom = 48 + rows * cell
avatars =
for {name, index} <- Enum.with_index(people) do
row = div(index, columns)
column = rem(index, columns)
%{
initials: initials(name),
on: index == assigns.highlight,
cx: Float.round(20 + column * cell + cell / 2, 1),
cy: Float.round(48 + row * cell + cell / 2, 1),
stagger: min(row + column + 2, 24)
}
end
marked = is_integer(assigns.highlight) && Enum.at(people, assigns.highlight)
assigns =
assigns
|> assign(:avatars, avatars)
|> assign(:radius, radius)
|> assign(:font, Float.round(radius * 0.4, 1))
|> assign(:count, if(length(people) == 1, do: "1 person", else: "#{length(people)} people"))
|> assign(:marked, marked)
|> assign(:bottom, Float.round(bottom, 1))
|> assign(:height, Float.round(if(marked, do: bottom + 48, else: bottom + 14), 1))
~H"""
<.frame name="avatars/grid" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 340 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<text class="ehv-mono" x="20" y="31" font-size="9">{String.upcase(@eyebrow)}</text>
<text class="ehv-mono ehv-t-4" x="320" y="31" font-size="9" text-anchor="end">
{String.upcase(@count)}
</text>
</g>
<g :for={avatar <- @avatars} class={"ehv-a-pop ehv-i-#{avatar.stagger}"}>
<g :if={avatar.on}>
<circle class="ehv-fill-lime ehv-a-ping" cx={avatar.cx} cy={avatar.cy} r={@radius} />
<circle class="ehv-line-lime" cx={avatar.cx} cy={avatar.cy} r={@radius + 4} />
<circle class="ehv-fill-lime" cx={avatar.cx} cy={avatar.cy} r={@radius} />
</g>
<circle
:if={!avatar.on}
class="ehv-card-3 ehv-avatars-grid__avatar"
cx={avatar.cx}
cy={avatar.cy}
r={@radius}
/>
<text
class={["ehv-mono", if(avatar.on, do: "ehv-t-on-lime", else: "ehv-t-2")]}
x={avatar.cx}
y={avatar.cy + @font * 0.36}
font-size={@font}
text-anchor="middle"
>
{avatar.initials}
</text>
</g>
<g :if={@marked} class="ehv-a-fade ehv-i-10">
<line class="ehv-line-faint" x1="20" x2="320" y1={@bottom + 6.5} y2={@bottom + 6.5} />
<circle class="ehv-fill-lime" cx="24" cy={@bottom + 25} r="3" />
<text x="36" y={@bottom + 29.5} font-size="12.5">{@marked}</text>
<text
class="ehv-mono ehv-t-lime"
x="320"
y={@bottom + 28.5}
font-size="8.5"
text-anchor="end"
>
{String.upcase(@note)}
</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/grid.css
/* avatars/grid */
.ehv-avatars-grid { --ehv-max-w: 370px; --ehv-step: 70ms; }
.ehv--hover .ehv-avatars-grid__avatar { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-avatars-grid__avatar:hover { stroke: var(--ehv-lime); }