empowered.house ↗

images/carousel.ex

Carousel

An image carousel with placeholder pictures, arrows and position dots, for galleries and walkthroughs.

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

<div class="h-96 rounded-xl border border-border">
  <.images_carousel animated />
</div>
default
isometric
first slide · custom copy
last slide · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Delivery photos"
slides list [%{title: "Dock 4, inbound", kind: "blocks"}, %{title: "Yard, north gate", kind: "hills"}, ...] Up to eight `%{title, kind}`. Kind is `hills`, `peak`, `sun`, `grid` or `blocks`. Titles up to 36 characters.
active integer 1 Index of the slide in the middle, from 0.
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/images/carousel.ex
carousel.ex
defmodule EHVisuals.Images.Carousel do
  @moduledoc """
  An image carousel: the current slide between the edges of its neighbours,
  two arrows and the position dots. The pictures are abstract placeholders.
  """

  use EHVisuals.Composition,
    name: "images/carousel",
    title: "Carousel",
    description:
      "An image carousel with placeholder pictures, arrows and position dots, for galleries and walkthroughs."

  @slides [
    %{title: "Dock 4, inbound", kind: "blocks"},
    %{title: "Yard, north gate", kind: "hills"},
    %{title: "Cold store, aisle 2", kind: "grid"},
    %{title: "Loading bay at dusk", kind: "sun"},
    %{title: "Site plan", kind: "peak"}
  ]

  attr(:eyebrow, :string, default: "Delivery photos")

  attr(:slides, :list,
    default: @slides,
    doc:
      "Up to eight `%{title, kind}`. Kind is `hills`, `peak`, `sun`, `grid` or `blocks`. Titles up to 36 characters."
  )

  attr(:active, :integer, default: 1, doc: "Index of the slide in the middle, from 0.")
  attr(:label, :string, default: "An image carousel showing the second of five pictures")
  frame_attrs()

  @spec images_carousel(map()) :: Phoenix.LiveView.Rendered.t()
  def images_carousel(assigns) do
    slides = Enum.take(assigns.slides, 8)
    count = max(length(slides), 1)
    active = assigns.active |> max(0) |> min(count - 1)
    current = Enum.at(slides, active, %{title: "", kind: "hills"})

    assigns =
      assigns
      |> assign(:scene, scene(current.kind, 64, 44, 232, 140))
      |> assign(:title, current.title)
      |> assign(:counter, "#{active + 1} / #{count}")
      |> assign(:previous, active > 0)
      |> assign(:next, active < count - 1)
      |> assign(:dots, dots(count, active))

    ~H"""
    <.frame name="images/carousel" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 360 246">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="245" rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="20" y="30" font-size="9">{String.upcase(@eyebrow)}</text>
            <text class="ehv-mono ehv-t-4" x="340" y="30" font-size="9" text-anchor="end">
              {@counter}
            </text>
          </g>
          <g :if={@previous} class="ehv-a-fade ehv-i-3">
            <path
              class="ehv-images-carousel__peek"
              d="M1 44.5 H42 a8 8 0 0 1 8 8 V175.5 a8 8 0 0 1 -8 8 H1"
            />
            <path class="ehv-line-soft" d="M1 153.5 H50 M1 153.5 C18 128 34 128 50 146" />
          </g>
          <g :if={@next} class="ehv-a-fade ehv-i-3">
            <path
              class="ehv-images-carousel__peek"
              d="M359 44.5 H318 a8 8 0 0 0 -8 8 V175.5 a8 8 0 0 0 8 8 H359"
            />
            <path class="ehv-line-soft" d="M310 153.5 H359 M310 153.5 C326 120 344 120 359 132" />
          </g>
          <g class="ehv-a-rise ehv-i-2">
            <g class="ehv-images-carousel__slide">
              <rect class="ehv-well" x="64.5" y="44.5" width="231" height="139" rx="8" />
              <circle
                :if={@scene.sun}
                class="ehv-fill-4 ehv-a-pop ehv-i-6"
                cx={elem(@scene.sun, 0)}
                cy={elem(@scene.sun, 1)}
                r={elem(@scene.sun, 2)}
              />
              <path
                class="ehv-line-ink ehv-round ehv-a-draw ehv-i-4"
                d={@scene.lines}
                pathLength="1"
              />
            </g>
          </g>
          <g class="ehv-a-pop ehv-i-5">
            <circle
              class={[
                "ehv-card-3 ehv-images-carousel__arrow",
                !@previous && "ehv-images-carousel__arrow--off"
              ]}
              cx="57"
              cy="114"
              r="13"
            />
            <path
              class={[
                "ehv-line-ink ehv-round ehv-images-carousel__chevron",
                !@previous && "ehv-images-carousel__arrow--off"
              ]}
              d="M59.5 109 l-5 5 l5 5"
            />
          </g>
          <g class="ehv-a-pop ehv-i-5">
            <circle
              class={[
                "ehv-card-3 ehv-images-carousel__arrow",
                !@next && "ehv-images-carousel__arrow--off"
              ]}
              cx="303"
              cy="114"
              r="13"
            />
            <path
              class={[
                "ehv-line-ink ehv-round ehv-images-carousel__chevron",
                !@next && "ehv-images-carousel__arrow--off"
              ]}
              d="M300.5 109 l5 5 l-5 5"
            />
          </g>
          <text class="ehv-a-fade ehv-i-6" x="180" y="209" font-size="12.5" text-anchor="middle">
            {@title}
          </text>
          <g :for={dot <- @dots} class={"ehv-a-pop ehv-i-#{dot.index + 7}"}>
            <rect
              class={if dot.on, do: "ehv-fill-lime", else: "ehv-fill-4"}
              x={dot.x}
              y="223"
              width={dot.w}
              height="6"
              rx="3"
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # The dots sit in one centred row. The active one is a longer pill.
  defp dots(count, active) do
    total = (count - 1) * 6 + 18 + (count - 1) * 6

    {dots, _x} =
      Enum.map_reduce(0..(count - 1), 180 - total / 2, fn index, x ->
        w = if index == active, do: 18, else: 6
        {%{index: index, on: index == active, x: Float.round(x, 1), w: w}, x + w + 6}
      end)

    dots
  end

  # A placeholder picture in the box at {x, y} of size {w, h}: hairlines for a
  # horizon and a landscape, and an optional sun as {cx, cy, r}.
  defp scene(kind, x, y, w, h) do
    px = fn f -> Float.round(x + f * w, 1) end
    py = fn f -> Float.round(y + f * h, 1) end
    r = Float.round(min(w, h) * 0.09, 1)

    case kind do
      "peak" ->
        %{
          lines:
            "M#{px.(0.08)} #{py.(0.78)} H#{px.(0.92)} " <>
              "M#{px.(0.16)} #{py.(0.78)} L#{px.(0.42)} #{py.(0.3)} L#{px.(0.55)} #{py.(0.52)} " <>
              "L#{px.(0.64)} #{py.(0.42)} L#{px.(0.86)} #{py.(0.78)}",
          sun: {px.(0.76), py.(0.24), r}
        }

      "sun" ->
        %{
          lines:
            "M#{px.(0.08)} #{py.(0.6)} H#{px.(0.92)} " <>
              "M#{px.(0.36)} #{py.(0.6)} A#{Float.round(0.14 * w, 1)} #{Float.round(0.14 * w, 1)} 0 0 1 #{px.(0.64)} #{py.(0.6)} " <>
              "M#{px.(0.4)} #{py.(0.71)} H#{px.(0.6)} M#{px.(0.45)} #{py.(0.81)} H#{px.(0.55)}",
          sun: nil
        }

      "grid" ->
        rays =
          Enum.map_join([0.08, 0.29, 0.5, 0.71, 0.92], " ", fn f ->
            "M#{px.(0.5)} #{py.(0.46)} L#{px.(f)} #{py.(0.86)}"
          end)

        rungs =
          Enum.map_join([0.3, 0.6, 1.0], " ", fn t ->
            "M#{px.(0.5 - 0.42 * t)} #{py.(0.46 + 0.4 * t)} H#{px.(0.5 + 0.42 * t)}"
          end)

        %{
          lines: "M#{px.(0.08)} #{py.(0.46)} H#{px.(0.92)} #{rays} #{rungs}",
          sun: {px.(0.74), py.(0.24), r}
        }

      "blocks" ->
        blocks =
          Enum.map_join(
            [{0.14, 0.27, 0.5}, {0.31, 0.48, 0.28}, {0.52, 0.66, 0.56}, {0.7, 0.86, 0.4}],
            " ",
            fn {left, right, top} ->
              "M#{px.(left)} #{py.(0.78)} V#{py.(top)} H#{px.(right)} V#{py.(0.78)}"
            end
          )

        %{lines: "M#{px.(0.08)} #{py.(0.78)} H#{px.(0.92)} #{blocks}", sun: nil}

      _hills ->
        %{
          lines:
            "M#{px.(0.08)} #{py.(0.78)} H#{px.(0.92)} " <>
              "M#{px.(0.1)} #{py.(0.78)} C#{px.(0.22)} #{py.(0.46)} #{px.(0.36)} #{py.(0.46)} #{px.(0.52)} #{py.(0.78)} " <>
              "M#{px.(0.4)} #{py.(0.66)} C#{px.(0.56)} #{py.(0.3)} #{px.(0.74)} #{py.(0.34)} #{px.(0.9)} #{py.(0.78)}",
          sun: {px.(0.24), py.(0.26), r}
        }
    end
  end
end
assets/css/images/carousel.css
carousel.css
/* images/carousel */
.ehv-images-carousel { --ehv-max-w: 400px; }

/* The edge of a neighbouring slide, open on the side that leaves the card. */
.ehv-images-carousel__peek { fill: var(--ehv-surface-2); stroke: var(--ehv-border-soft); stroke-width: 1; }

.ehv-images-carousel__chevron { pointer-events: none; }
.ehv-images-carousel__arrow--off { opacity: 0.35; }

.ehv--hover .ehv-images-carousel__arrow { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-images-carousel__arrow:hover { stroke: var(--ehv-lime); }
.ehv--hover .ehv-images-carousel__slide { transition: transform 260ms var(--ehv-ease); }
.ehv--hover:hover .ehv-images-carousel__slide { transform: translateX(-4px); }