empowered.house ↗

integrations/logo-reel.ex

Logo reel

A row of system tiles stepping through a centre spotlight, with the name of the one in it underneath.

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

<div class="h-96 rounded-xl border border-border">
  <.integrations_logo_reel animated />
</div>
default
isometric
three systems · custom copy
eight systems · fourth active · hover
light surface
isometric · fade out · still

Props

Prop Type Default What it does
systems list [%{name: "ERP", caption: "Orders, stock and invoices"}, %{name: "CRM", caption: "Customers and open deals"}, ...] Three to eight `%{name, caption}`. The name is six characters at most, the caption 40.
active integer 0 Index of the system that sits in the spotlight in the still frame.
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-reel.ex
logo-reel.ex
defmodule EHVisuals.Integrations.LogoReel do
  @moduledoc """
  A row of system tiles with one in a centre spotlight and its name
  underneath. The tiles step through the spotlight one by one.

  The reel is two cycles wide. One animation jumps it a tile at a time over a
  whole cycle, a second one slides it by one tile at the end of every step,
  so the two together read as a row that steps and never ends.
  """

  use EHVisuals.Composition,
    name: "integrations/logo-reel",
    title: "Logo reel",
    description:
      "A row of system tiles stepping through a centre spotlight, with the name of the one in it underneath."

  @tile 72
  @pitch 92
  @center 164
  @lead 3

  @systems [
    %{name: "ERP", caption: "Orders, stock and invoices"},
    %{name: "CRM", caption: "Customers and open deals"},
    %{name: "Mail", caption: "The shared order inbox"},
    %{name: "Sheets", caption: "The price list the team keeps"},
    %{name: "Bank", caption: "Statements and payments"},
    %{name: "WMS", caption: "Warehouse movements"}
  ]

  # Glyphs are drawn in a 12 by 12 box.
  @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(:systems, :list,
    default: @systems,
    doc: "Three to eight `%{name, caption}`. The name is six characters at most, the caption 40."
  )

  attr(:active, :integer,
    default: 0,
    doc: "Index of the system that sits in the spotlight in the still frame."
  )

  attr(:label, :string,
    default: "A row of company system tiles with the ERP in the spotlight and its name underneath"
  )

  frame_attrs()

  @spec integrations_logo_reel(map()) :: Phoenix.LiveView.Rendered.t()
  def integrations_logo_reel(assigns) do
    systems = Enum.take(assigns.systems, 8)
    count = max(length(systems), 1)
    active = assigns.active |> max(0) |> min(count - 1)
    # A cycle is the systems repeated until it is at least seven tiles long.
    cycle = count * ceil(7 / count)

    tiles =
      for position <- 0..(2 * cycle - 1) do
        system = Enum.at(systems, Integer.mod(active - @lead + position, count), %{name: ""})

        %{
          name: system.name,
          glyph: Map.get(@glyphs, String.upcase(system.name), @fallback_glyph),
          x: @center + (position - @lead) * @pitch
        }
      end

    labels =
      for {system, index} <- Enum.with_index(systems) do
        %{
          name: system.name,
          caption: Map.get(system, :caption, ""),
          on: index == active,
          step: Integer.mod(index - active, count),
          dot_x: Float.round(200 + (index - (count - 1) / 2) * 12, 1)
        }
      end

    assigns =
      assigns
      |> assign(:tiles, tiles)
      |> assign(:labels, labels)
      |> assign(:count, count)
      |> assign(:cycle, cycle)
      |> assign(:reel_x, @center - @lead * @pitch)
      |> assign(:reel_w, 2 * cycle * @pitch)
      |> assign(:tile, @tile)

    ~H"""
    <.frame name="integrations/logo-reel" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 400 208">
        <g class="ehv-h-lift">
          <rect
            class="ehv-integrations-logo-reel__spot ehv-a-pop"
            x="152"
            y="12"
            width="96"
            height="96"
            rx="20"
          />
          <g class="ehv-a-fade ehv-i-1">
            <g class={"ehv-integrations-logo-reel__reel ehv-integrations-logo-reel__reel--m#{@cycle}"}>
              <g class="ehv-integrations-logo-reel__slide">
                <rect class="ehv-fill-none" x={@reel_x} y="24" width={@reel_w} height={@tile} />
                <g :for={tile <- @tiles}>
                  <rect class="ehv-card" x={tile.x} y="24" width={@tile} height={@tile} rx="16" />
                  <path
                    class="ehv-line-ink ehv-round ehv-integrations-logo-reel__glyph"
                    d={tile.glyph}
                    transform={"translate(#{tile.x + 26.4} #{38}) scale(1.6)"}
                  />
                  <text
                    class="ehv-mono ehv-t-2"
                    x={tile.x + 36}
                    y="79"
                    font-size="8"
                    text-anchor="middle"
                  >
                    {String.upcase(tile.name)}
                  </text>
                </g>
              </g>
            </g>
          </g>
          <g class="ehv-a-fade ehv-i-3">
            <line class="ehv-line-lime" x1="200" x2="200" y1="108" y2="118" />
            <circle :for={label <- @labels} class="ehv-fill-4" cx={label.dot_x} cy="184" r="2.5" />
            <g class={"ehv-integrations-logo-reel__labels--n#{@count}"}>
              <g
                :for={label <- @labels}
                class={[
                  "ehv-integrations-logo-reel__label ehv-i-#{label.step}",
                  label.on && "ehv-integrations-logo-reel__label--on"
                ]}
              >
                <text class="ehv-mono ehv-t-lime" x="200" y="138" font-size="10" text-anchor="middle">
                  {String.upcase(label.name)}
                </text>
                <text x="200" y="159" font-size="13" text-anchor="middle">{label.caption}</text>
                <circle class="ehv-fill-lime" cx={label.dot_x} cy="184" r="3.5" />
              </g>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/integrations/logo-reel.css
logo-reel.css
/* integrations/logo-reel */
.ehv-integrations-logo-reel { --ehv-max-w: 460px; --ehv-iso-scale: 0.82; --ehv-reel-step: 2.6s; }

/* The row runs past both edges and fades out there. The mask sits on the SVG
   itself, so it works without an id in the markup. */
.ehv-integrations-logo-reel .ehv__stage > svg {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 24%, #000 76%, transparent);
  mask-image: linear-gradient(to right, transparent, #000 24%, #000 76%, transparent);
}

.ehv-integrations-logo-reel__spot { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1.25; }
.ehv-integrations-logo-reel__glyph { stroke-width: 0.8; }

/* Only the name of the tile in the spotlight shows. */
.ehv-integrations-logo-reel__label { opacity: 0; }
.ehv-integrations-logo-reel__label--on { opacity: 1; }

/* The reel is two cycles wide: half its width is one cycle, taken a tile at
   a time. The slide inside moves one tile (92) at the end of every step. */
@keyframes ehv-integrations-logo-reel-jump { to { transform: translateX(-50%); } }
@keyframes ehv-integrations-logo-reel-slide {
  0%, 68% { transform: translateX(0); }
  100% { transform: translateX(-92px); }
}

.ehv-integrations-logo-reel__reel { transform-box: fill-box; }
.ehv--animated .ehv-integrations-logo-reel__reel { animation: ehv-integrations-logo-reel-jump linear infinite; }
.ehv--animated .ehv-integrations-logo-reel__reel--m7 { animation-duration: calc(7 * var(--ehv-reel-step)); animation-timing-function: steps(7); }
.ehv--animated .ehv-integrations-logo-reel__reel--m8 { animation-duration: calc(8 * var(--ehv-reel-step)); animation-timing-function: steps(8); }
.ehv--animated .ehv-integrations-logo-reel__reel--m9 { animation-duration: calc(9 * var(--ehv-reel-step)); animation-timing-function: steps(9); }
.ehv--animated .ehv-integrations-logo-reel__reel--m10 { animation-duration: calc(10 * var(--ehv-reel-step)); animation-timing-function: steps(10); }
.ehv--animated .ehv-integrations-logo-reel__reel--m12 { animation-duration: calc(12 * var(--ehv-reel-step)); animation-timing-function: steps(12); }
.ehv--animated .ehv-integrations-logo-reel__slide { animation: ehv-integrations-logo-reel-slide var(--ehv-reel-step) var(--ehv-ease) infinite; }

/* A name shows for one step out of n and leaves as the row starts to slide.
   Its stagger index is the step it belongs to, so a negative delay puts
   every name at its place in the loop. */
@keyframes ehv-integrations-logo-reel-show-1 { 0%, 100% { opacity: 1; } }
@keyframes ehv-integrations-logo-reel-show-2 { 0% { opacity: 0; } 4%, 34% { opacity: 1; } 40%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-3 { 0% { opacity: 0; } 3%, 22.7% { opacity: 1; } 26.7%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-4 { 0% { opacity: 0; } 2%, 17% { opacity: 1; } 20%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-5 { 0% { opacity: 0; } 2%, 13.6% { opacity: 1; } 16%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-6 { 0% { opacity: 0; } 1.5%, 11.3% { opacity: 1; } 13.3%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-7 { 0% { opacity: 0; } 1.3%, 9.7% { opacity: 1; } 11.4%, 100% { opacity: 0; } }
@keyframes ehv-integrations-logo-reel-show-8 { 0% { opacity: 0; } 1.1%, 8.5% { opacity: 1; } 10%, 100% { opacity: 0; } }

.ehv--animated .ehv-integrations-logo-reel__label {
  animation: linear infinite;
  animation-duration: calc(var(--ehv-reel-n) * var(--ehv-reel-step));
  animation-delay: calc((var(--ehv-i, 0) - var(--ehv-reel-n)) * var(--ehv-reel-step));
}
.ehv-integrations-logo-reel__labels--n1 { --ehv-reel-n: 1; }
.ehv-integrations-logo-reel__labels--n2 { --ehv-reel-n: 2; }
.ehv-integrations-logo-reel__labels--n3 { --ehv-reel-n: 3; }
.ehv-integrations-logo-reel__labels--n4 { --ehv-reel-n: 4; }
.ehv-integrations-logo-reel__labels--n5 { --ehv-reel-n: 5; }
.ehv-integrations-logo-reel__labels--n6 { --ehv-reel-n: 6; }
.ehv-integrations-logo-reel__labels--n7 { --ehv-reel-n: 7; }
.ehv-integrations-logo-reel__labels--n8 { --ehv-reel-n: 8; }
.ehv--animated .ehv-integrations-logo-reel__labels--n1 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-1; }
.ehv--animated .ehv-integrations-logo-reel__labels--n2 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-2; }
.ehv--animated .ehv-integrations-logo-reel__labels--n3 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-3; }
.ehv--animated .ehv-integrations-logo-reel__labels--n4 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-4; }
.ehv--animated .ehv-integrations-logo-reel__labels--n5 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-5; }
.ehv--animated .ehv-integrations-logo-reel__labels--n6 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-6; }
.ehv--animated .ehv-integrations-logo-reel__labels--n7 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-7; }
.ehv--animated .ehv-integrations-logo-reel__labels--n8 .ehv-integrations-logo-reel__label { animation-name: ehv-integrations-logo-reel-show-8; }

.ehv--hover:hover :is(.ehv-integrations-logo-reel__reel, .ehv-integrations-logo-reel__slide, .ehv-integrations-logo-reel__label) { animation-play-state: paused; }