chat/bubbles.ex
Bubbles
A conversation in bubbles between a customer and the team, ending with a typing indicator.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.chat_bubbles animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
customer |
string | "Piotr Zielinski" |
|
meta |
string | "Northwind · PO-2291" |
|
team |
string | "Marta · Support" |
The name over the team's bubbles. |
messages |
list | [%{text: "PO-2291 was due Tuesday. Any news?", from: "customer"}, %{text: "It left the warehouse this morning.", from: "team"}, ...] |
Up to six `%{from, text}`, oldest first. `from` is `customer` or `team`. The text wraps at 36 characters, up to three lines. |
typing |
"team" | "customer" | "none" | "team" |
Who is typing in the last bubble. |
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/chat/bubbles.ex
defmodule EHVisuals.Chat.Bubbles do
@moduledoc """
A conversation between a customer and the team in bubbles, ending with
somebody typing.
"""
use EHVisuals.Composition,
name: "chat/bubbles",
title: "Bubbles",
description:
"A conversation in bubbles between a customer and the team, ending with a typing indicator."
@messages [
%{from: "customer", text: "PO-2291 was due Tuesday. Any news?"},
%{from: "team", text: "It left the warehouse this morning."},
%{from: "team", text: "The carrier moved its cut-off, so it arrives on Friday."},
%{from: "customer", text: "Friday works. Tracking link, please?"}
]
attr(:customer, :string, default: "Piotr Zielinski")
attr(:meta, :string, default: "Northwind · PO-2291")
attr(:team, :string, default: "Marta · Support", doc: "The name over the team's bubbles.")
attr(:messages, :list,
default: @messages,
doc:
"Up to six `%{from, text}`, oldest first. `from` is `customer` or `team`. The text wraps at 36 characters, up to three lines."
)
attr(:typing, :string,
default: "team",
values: ~w(team customer none),
doc: "Who is typing in the last bubble."
)
attr(:label, :string,
default: "A conversation between a customer and the team about a late order, the team typing"
)
frame_attrs()
@spec chat_bubbles(map()) :: Phoenix.LiveView.Rendered.t()
def chat_bubbles(assigns) do
entries =
assigns.messages
|> Enum.take(6)
|> Enum.map(&%{from: &1.from, lines: &1.text |> wrap(36) |> Enum.take(3)})
entries =
if assigns.typing == "none",
do: entries,
else: entries ++ [%{from: assigns.typing, lines: nil}]
{rows, bottom} = layout(entries)
first = assigns.customer |> String.split() |> List.first() || ""
assigns =
assigns
|> assign(:rows, rows)
|> assign(:initials, initials(assigns.customer))
|> assign(:names, %{"customer" => first, "team" => assigns.team})
|> assign(:height, bottom + 18)
~H"""
<.frame name="chat/bubbles" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 360 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<circle class="ehv-card-3" cx="33" cy="28" r="13" />
<text class="ehv-mono ehv-t-2" x="33" y="31" font-size="8" text-anchor="middle">
{@initials}
</text>
<text class="ehv-t-strong" x="56" y="25.5" font-size="13">{@customer}</text>
<text class="ehv-mono" x="56" y="39" font-size="8">{String.upcase(@meta)}</text>
<line class="ehv-line-faint" x1="20" x2="340" y1="52.5" y2="52.5" />
</g>
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{min(row.index * 2 + 2, 24)}"}>
<text
:if={row.named}
class="ehv-mono ehv-t-4"
x={if row.team, do: 340, else: 20}
y={row.y - 6}
font-size="7.5"
text-anchor={if row.team, do: "end", else: "start"}
>
{String.upcase(Map.get(@names, row.from, ""))}
</text>
<path
class={if row.team, do: "ehv-chat-bubbles__team", else: "ehv-card-3"}
d={bubble(row.x, row.y, row.w, row.h, row.team)}
/>
<text
:for={{line, index} <- Enum.with_index(row.lines || [])}
x={row.x + 12}
y={row.y + 19 + index * 16}
font-size="12"
>
{line}
</text>
<circle
:for={dot <- 0..2}
:if={row.lines == nil}
class={[
"ehv-chat-bubbles__dot ehv-i-#{dot}",
row.team && "ehv-chat-bubbles__dot--team"
]}
cx={row.x + 14 + dot * 9}
cy={row.y + 14}
r="2.5"
/>
</g>
</g>
</svg>
</.frame>
"""
end
# Stacks the bubbles from y = 62: close together inside a run of one sender,
# further apart and with a name when the sender changes.
defp layout(entries) do
{rows, {bottom, _from}} =
entries
|> Enum.with_index()
|> Enum.map_reduce({62, nil}, fn {entry, index}, {y, previous} ->
named = entry.from != previous
team = entry.from == "team"
top = y + if(named, do: 16, else: 0) + if(previous && named, do: 6, else: 0)
{w, h} = size(entry.lines)
row = %{
index: index,
from: entry.from,
team: team,
named: named,
lines: entry.lines,
x: Float.round(if(team, do: 340 - w, else: 20.0), 1),
y: top,
w: w,
h: h
}
{row, {top + h + 5, entry.from}}
end)
{rows, bottom - 5}
end
defp size(nil), do: {46.0, 28}
defp size(lines) do
longest = lines |> Enum.map(&String.length/1) |> Enum.max(fn -> 0 end)
{Float.round(24 + longest * 6.0, 1), 12 + length(lines) * 16}
end
# A rounded bubble with one tight corner, bottom right for the team and
# bottom left for the customer.
defp bubble(x, y, w, h, team) do
{right, left} = if team, do: {3, 11}, else: {11, 3}
"M#{x + 11} #{y} H#{x + w - 11} a11 11 0 0 1 11 11 V#{y + h - right} " <>
"a#{right} #{right} 0 0 1 -#{right} #{right} H#{x + left} " <>
"a#{left} #{left} 0 0 1 -#{left} -#{left} V#{y + 11} a11 11 0 0 1 11 -11 z"
end
defp wrap(text, width) do
lines =
text
|> String.split()
|> Enum.reduce([], fn
word, [] ->
[word]
word, [line | rest] ->
if String.length(line) + 1 + String.length(word) <= width,
do: [line <> " " <> word | rest],
else: [word, line | rest]
end)
|> Enum.reverse()
if lines == [], do: [""], else: lines
end
defp initials(name) do
name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
end
end
assets/css/chat/bubbles.css
/* chat/bubbles */
.ehv-chat-bubbles { --ehv-max-w: 390px; --ehv-step: 130ms; }
.ehv-chat-bubbles__team { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1; stroke-opacity: 0.35; }
.ehv-chat-bubbles__dot { fill: var(--ehv-ink-3); transform-box: fill-box; transform-origin: center; }
.ehv-chat-bubbles__dot--team { fill: var(--ehv-lime); }
/* Typing: the three dots hop one after the other. */
@keyframes ehv-chat-bubbles-hop {
0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
30% { transform: translateY(-3px); opacity: 1; }
}
.ehv--animated .ehv-chat-bubbles__dot {
animation: ehv-chat-bubbles-hop 1.2s ease-in-out infinite;
animation-delay: calc(var(--ehv-i, 0) * 160ms);
}