integrations/logo-marquee.ex
Logo marquee
Rows of system tiles drifting in alternating directions and fading out at both edges.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.integrations_logo_marquee animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Connects to the systems you already run" |
The line above the rows, 44 characters at most. Empty hides it. |
rows |
list | [["ERP", "CRM", "Mail", "Sheets", "Bank"], ...] |
One to four rows, each a list of system names of six characters at most. |
highlight |
any | nil |
The name of one system to outline in lime wherever it appears, or `nil`. |
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/integrations/logo-marquee.ex
defmodule EHVisuals.Integrations.LogoMarquee do
@moduledoc """
Rows of system tiles that drift in alternating directions and fade out at
the left and right edges.
Each row is drawn twice in one track. The track is exactly two cycles wide,
so the loop moves it by half its own width and lands on the same picture.
"""
use EHVisuals.Composition,
name: "integrations/logo-marquee",
title: "Logo marquee",
description:
"Rows of system tiles drifting in alternating directions and fading out at both edges."
@tile_w 100
@tile_h 46
@pitch 114
@row_pitch 60
# One cycle has to be wider than the picture and its overflow.
@min_cycle 660
@starts [-34, -91, -62, -20]
# Glyphs are drawn in a 12 by 12 box with a 1.25 stroke.
@glyphs %{
"ERP" => "M1.5 1.5h9v9h-9z M6 1.5v9 M1.5 6h9",
"CRM" =>
"M6 5.4a2.2 2.2 0 1 0 0-4.4a2.2 2.2 0 0 0 0 4.4z M1.8 11c0-2.2 1.9-3.6 4.2-3.6s4.2 1.4 4.2 3.6",
"MAIL" => "M1 2.5h10v7h-10z M1 3l5 4l5-4",
"SHEETS" => "M1.5 1.5h9v9h-9z M1.5 4.5h9 M1.5 7.5h9 M4.5 1.5v9",
"BANK" => "M1 4.5l5-3l5 3z M2.5 5v4.5 M6 5v4.5 M9.5 5v4.5 M1 10.5h10",
"WMS" => "M1.5 3.5l4.5-2l4.5 2v5l-4.5 2l-4.5-2z M1.5 3.5l4.5 2l4.5-2 M6 5.5v5",
"HR" => "M2 1.5h8v9h-8z M6 5.6a1.4 1.4 0 1 0 0-2.8a1.4 1.4 0 0 0 0 2.8z M3.8 8.4h4.4",
"BI" => "M2.5 10.5v-4 M6 10.5v-8 M9.5 10.5v-6 M1 10.5h10",
"CHAT" => "M1.5 2h9v6h-5l-2.5 2.5v-2.5h-1.5z",
"DOCS" => "M2.5 1.5h5l2 2v7h-7z M4.5 5.5h3 M4.5 7.8h3"
}
@fallback_glyph "M6 2.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z"
attr(:caption, :string,
default: "Connects to the systems you already run",
doc: "The line above the rows, 44 characters at most. Empty hides it."
)
attr(:rows, :list,
default: [~w(ERP CRM Mail Sheets Bank), ~w(WMS HR BI Chat Docs), ~w(Bank Docs ERP Chat CRM)],
doc: "One to four rows, each a list of system names of six characters at most."
)
attr(:highlight, :any,
default: nil,
doc: "The name of one system to outline in lime wherever it appears, or `nil`."
)
attr(:label, :string,
default: "Three rows of company system tiles moving in alternating directions"
)
frame_attrs()
@spec integrations_logo_marquee(map()) :: Phoenix.LiveView.Rendered.t()
def integrations_logo_marquee(assigns) do
top = if assigns.caption == "", do: 4, else: 34
on = assigns.highlight && String.upcase(to_string(assigns.highlight))
tracks =
assigns.rows
|> Enum.reject(&(&1 == []))
|> Enum.take(4)
|> Enum.with_index()
|> Enum.map(fn {names, index} -> track(names, index, top, on) end)
caption_w = String.length(assigns.caption) * 5.78
assigns =
assigns
|> assign(:tracks, tracks)
|> assign(:dot_x, Float.round(220 - caption_w / 2 - 9, 1))
|> assign(:height, top + max(length(tracks), 1) * @row_pitch - (@row_pitch - @tile_h) + 4)
|> assign(:tile_w, @tile_w)
|> assign(:tile_h, @tile_h)
~H"""
<.frame name="integrations/logo-marquee" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 440 #{@height}"}>
<g class="ehv-h-lift">
<g :if={@caption != ""} class="ehv-a-fade">
<circle class="ehv-fill-lime ehv-a-ping" cx={@dot_x} cy="13" r="3" />
<circle class="ehv-fill-lime" cx={@dot_x} cy="13" r="3" />
<text class="ehv-mono ehv-t-2" x="224" y="16" font-size="8.5" text-anchor="middle">
{String.upcase(@caption)}
</text>
</g>
<g :for={track <- @tracks} class={"ehv-a-fade ehv-i-#{track.index + 1}"}>
<g class={"ehv-integrations-logo-marquee__track ehv-integrations-logo-marquee__track--#{track.direction}"}>
<rect
class="ehv-fill-none"
x={track.x}
y={track.y}
width={track.width}
height={@tile_h}
/>
<g :for={tile <- track.tiles}>
<rect
class={[
"ehv-card ehv-integrations-logo-marquee__tile",
tile.on && "ehv-integrations-logo-marquee__tile--on"
]}
x={tile.x}
y={track.y}
width={@tile_w}
height={@tile_h}
rx="11"
/>
<rect
class="ehv-card-3"
x={tile.x + 10}
y={track.y + 10}
width="26"
height="26"
rx="7"
/>
<path
class={["ehv-round", if(tile.on, do: "ehv-line-lime", else: "ehv-line-ink")]}
d={tile.glyph}
transform={"translate(#{tile.x + 17} #{track.y + 17})"}
/>
<text
class={["ehv-mono", if(tile.on, do: "ehv-t-lime", else: "ehv-t-2")]}
x={tile.x + 46}
y={track.y + 26.5}
font-size="9"
>
{String.upcase(tile.name)}
</text>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# A row repeats its names until one cycle is wider than the picture, then
# the cycle is laid out twice.
defp track(names, index, top, on) do
repeats = ceil(@min_cycle / (length(names) * @pitch))
cycle = names |> List.duplicate(repeats) |> List.flatten()
start = Enum.at(@starts, index)
tiles =
for {name, position} <- Enum.with_index(cycle ++ cycle) do
%{
name: name,
on: String.upcase(name) == on,
glyph: Map.get(@glyphs, String.upcase(name), @fallback_glyph),
x: start + position * @pitch
}
end
%{
index: index,
direction: if(rem(index, 2) == 0, do: "left", else: "right"),
x: start,
y: top + index * @row_pitch,
width: 2 * length(cycle) * @pitch,
tiles: tiles
}
end
end
assets/css/integrations/logo-marquee.css
/* integrations/logo-marquee */
.ehv-integrations-logo-marquee { --ehv-max-w: 500px; --ehv-iso-scale: 0.82; }
/* The rows run past both edges of the picture and fade out there. The mask
sits on the SVG itself, so it works without an id in the markup. */
.ehv-integrations-logo-marquee .ehv__stage > svg {
-webkit-mask-image: linear-gradient(to right, transparent, #000 17%, #000 83%, transparent);
mask-image: linear-gradient(to right, transparent, #000 17%, #000 83%, transparent);
}
/* A track is two cycles wide, so half its own width is one cycle. */
@keyframes ehv-integrations-logo-marquee-left { to { transform: translateX(-50%); } }
@keyframes ehv-integrations-logo-marquee-right { from { transform: translateX(-50%); } }
.ehv-integrations-logo-marquee__track { transform-box: fill-box; }
.ehv--animated .ehv-integrations-logo-marquee__track--left { animation: ehv-integrations-logo-marquee-left 54s linear infinite; }
.ehv--animated .ehv-integrations-logo-marquee__track--right { animation: ehv-integrations-logo-marquee-right 66s linear infinite; }
.ehv-integrations-logo-marquee__tile--on { stroke: var(--ehv-lime); }
.ehv--hover .ehv-integrations-logo-marquee__tile { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-integrations-logo-marquee__tile:hover { stroke: var(--ehv-lime); }
.ehv--hover:hover .ehv-integrations-logo-marquee__track { animation-play-state: paused; }