images/carousel.ex
Carousel
An image carousel with placeholder pictures, arrows and position dots, for galleries and walkthroughs.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.images_carousel animated />
</div>
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
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
/* 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); }