Components
Snap List
The bare scroll-snapping rail — gallery strip, chip row, media shelf, date rail — built entirely on native CSS scroll-snap with zero JavaScript. It's the un-opinionated scroller the Carousel dresses up with Prev/Next controls.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/snap-list.json2. Use it
import { SnapList, SnapListItem } from "@/components/ui/snap-list"
<SnapList ariaLabel="Photo strip">
<SnapListItem><img src="/1.jpg" alt="…" /></SnapListItem>
<SnapListItem><img src="/2.jpg" alt="…" /></SnapListItem>
</SnapList>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Snap List — shadcn-htmx, htmx v4 + Tailwind v4.
//
// The bare, reusable scroll-snapping rail: a gallery strip, chip row, media
// shelf, or date rail. This is the un-opinionated scroller that our Carousel
// (registry/ui/carousel.tsx) dresses up with Prev/Next buttons + carousel
// ARIA — here there are no controls, just native scrolling that snaps.
//
// Built entirely on CSS scroll snap — ZERO JavaScript. The platform owns the
// scrolling (mouse wheel, trackpad, touch swipe, and the browser's own
// keyboard scrolling of a focusable scroll region) and the snap behaviour:
// - scroll-snap-type on the scroll container opts it into snapping and sets
// the axis (x/y) + strictness (mandatory/proximity):
// repos/mdn/files/en-us/web/css/reference/properties/scroll-snap-type/index.md
// - scroll-snap-align on each child sets where it snaps (start/center/end):
// repos/mdn/files/en-us/web/css/reference/properties/scroll-snap-align/index.md
// - scroll-snap-stop: always forces the scroll to stop on each item rather
// than flinging past several at once:
// repos/mdn/files/en-us/web/css/reference/properties/scroll-snap-stop/index.md
// Pattern reference (a horizontal, accessible, library-free media shelf):
// repos/web.dev/src/site/content/en/patterns/components/media-scroller/index.md
//
// Tailwind v4 ships every utility we need natively, so no custom CSS:
// snap-x / snap-y, snap-mandatory / snap-proximity, snap-start / snap-center
// / snap-end, snap-always, scroll-pl / scroll-pt (scroll-padding so snapped
// items aren't flush to the edge), scroll-smooth.
// See repos/tailwindcss/packages/tailwindcss/src/utilities.ts:1846-1867.
// The .scrollbar-none helper (scrollbar-width:none + the WebKit supplement) is
// the same one the Carousel uses; it lives in app/styles/input.css.
//
// Native, future-facing styling hook (no JS, no extra CSS shipped here): a
// snapped item can be highlighted purely in CSS with a scroll-state container
// query — `@container scroll-state(snapped: x)` — once you opt the item into
// `container-type: scroll-state`. We don't bake that in (it needs a CSS rule
// we'd have to ship), but the rail is the snap container it queries:
// repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md
//
// Semantics: this is a *list*, so the root is a real <ul> with role="list"
// (Safari drops the implicit list role once list-style is removed, so we set
// it back) and each item is an <li>. A scrollable region must be a tab stop to
// be operable by keyboard-only users, so the <ul> carries tabindex="0" and a
// visible focus ring. Name it via aria-label / aria-labelledby.
// repos/mdn/files/en-us/web/html/reference/elements/ul/index.md
export type SnapListOrientation = "horizontal" | "vertical"
export type SnapListStrictness = "mandatory" | "proximity"
export type SnapListAlign = "start" | "center" | "end"
// The scroll container. We always set scroll-smooth (so any programmatic
// scrollIntoView animates), hide the scrollbar chrome, and add a focus ring
// because the region is a tab stop. The axis + strictness come from the maps.
const listBase =
"flex list-none scroll-smooth scrollbar-none rounded-lg outline-none " +
"focus-visible:ring-[3px] focus-visible:ring-ring/50"
// Axis: horizontal scrolls on x (row), vertical scrolls on y (column). The
// scroll container must overflow on the snap axis for snapping to engage.
const orientations: Record<SnapListOrientation, string> = {
horizontal: "snap-x flex-row overflow-x-auto",
vertical: "snap-y flex-col overflow-y-auto",
}
const strictnesses: Record<SnapListStrictness, string> = {
mandatory: "snap-mandatory",
proximity: "snap-proximity",
}
// Each item never shrinks below its content/basis and declares its snap line.
const itemBase = "min-w-0 shrink-0 grow-0"
const aligns: Record<SnapListAlign, string> = {
start: "snap-start",
center: "snap-center",
end: "snap-end",
}
type SnapListProps = PropsWithChildren<{
// Scroll/snap axis. horizontal (default) is the gallery-strip / chip-row
// case; vertical is a snapping column. Drives scroll-snap-type's axis.
orientation?: SnapListOrientation
// scroll-snap-type strictness. mandatory always rests on a snap point;
// proximity only snaps when a rest point is near (gentler on long content).
snap?: SnapListStrictness
// Accessible name for the list region (required when there's no visible
// heading): becomes aria-label / aria-labelledby on the <ul>.
ariaLabel?: string
ariaLabelledby?: string
class?: ClassValue
// htmx + arbitrary attributes ride onto the root scroll container.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function SnapList(props: SnapListProps) {
const {
orientation = "horizontal",
snap = "mandatory",
ariaLabel,
ariaLabelledby,
class: className,
children,
...rest
} = props as any
return (
<ul
data-slot="snap-list"
data-orientation={orientation}
data-snap={snap}
role="list"
tabindex={0}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class={cn(listBase, orientations[orientation as SnapListOrientation], strictnesses[snap as SnapListStrictness], className)}
{...rest}
>
{children}
</ul>
)
}
type SnapListItemProps = PropsWithChildren<{
// Override the rail's default snap-align for this item.
align?: SnapListAlign
// scroll-snap-stop: always — the scroll cannot fling past this item; it must
// come to rest on it. Use it to guarantee every item gets a stop.
stop?: boolean
class?: ClassValue
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function SnapListItem(props: SnapListItemProps) {
const { align = "start", stop, class: className, children, ...rest } = props as any
return (
<li
data-slot="snap-list-item"
data-align={align}
class={cn(itemBase, aligns[align as SnapListAlign], stop && "snap-always", className)}
{...rest}
>
{children}
</li>
)
}
1. Save the file
Copy snap-list.html into templates/components/.
2. Use it
{% from "components/snap-list.html" import snap_list_open, snap_list_close, snap_list_item %}
{{ snap_list_open(aria_label="Photo strip") }}
{% call(_) snap_list_item() %}<img src="/1.jpg" alt="…">{% endcall %}
{% call(_) snap_list_item() %}<img src="/2.jpg" alt="…">{% endcall %}
{{ snap_list_close() }}View source
{# Snap List macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/snap-list.tsx. The bare, reusable CSS scroll-snap rail
(gallery strip / chip row / media shelf / date rail) — ZERO JavaScript.
Built on CSS scroll snap:
- scroll-snap-type on the <ul> (snap-x/snap-y + snap-mandatory/snap-proximity)
repos/mdn/.../css/reference/properties/scroll-snap-type
- scroll-snap-align on each <li> (snap-start/snap-center/snap-end)
repos/mdn/.../css/reference/properties/scroll-snap-align
- scroll-snap-stop: always (snap-always) so a fling can't skip an item
repos/mdn/.../css/reference/properties/scroll-snap-stop
Pattern: repos/web.dev/.../patterns/components/media-scroller
The root is a real <ul role="list"> (Safari drops the implicit list role
once list-style is removed) and a keyboard tab stop (tabindex="0") so the
scroll region is operable by keyboard. Name it via aria_label / aria_labelledby.
The .scrollbar-none helper lives in app/styles/input.css.
Usage:
{% from "components/snap-list.html" import snap_list_open, snap_list_close, snap_list_item %}
{{ snap_list_open(aria_label="Photo strip") }}
{% call(_) snap_list_item() %}<img src="…" alt="…">{% endcall %}
{% call(_) snap_list_item() %}<img src="…" alt="…">{% endcall %}
{{ snap_list_close() }} #}
{% macro snap_list_open(orientation="horizontal", snap="mandatory", aria_label=none, aria_labelledby=none, extra_class="", attrs={}) -%}
{%- set axis = "snap-x flex-row overflow-x-auto" if orientation == "horizontal" else "snap-y flex-col overflow-y-auto" -%}
{%- set strictness = "snap-mandatory" if snap == "mandatory" else "snap-proximity" -%}
<ul data-slot="snap-list"
data-orientation="{{ orientation }}"
data-snap="{{ snap }}"
role="list"
tabindex="0"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 {{ axis }} {{ strictness }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{%- endmacro %}
{% macro snap_list_close() %}</ul>{% endmacro %}
{% macro snap_list_item(align="start", stop=false, extra_class="") %}
{%- set align_class = "snap-start" if align == "start" else ("snap-center" if align == "center" else "snap-end") -%}
<li data-slot="snap-list-item"
data-align="{{ align }}"
class="min-w-0 shrink-0 grow-0 {{ align_class }}{% if stop %} snap-always{% endif %} {{ extra_class }}">
{{ caller() }}
</li>
{% endmacro %}
1. Save the file
Add snap-list.tmpl alongside your templates.
2. Use it
{{template "snap_list" (dict "AriaLabel" "Photo strip"
"Body" (htmlSafe (printf "%s%s"
(... {{template "snap_list_item" (dict "Body" (htmlSafe "<img src=\"/1.jpg\" alt=\"…\">"))}} ...)
(... {{template "snap_list_item" (dict "Body" (htmlSafe "<img src=\"/2.jpg\" alt=\"…\">"))}} ...))))}}View source
{{/*
Snap List template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/snap-list.tsx. The bare, reusable CSS scroll-snap rail
(gallery strip / chip row / media shelf / date rail) — ZERO JavaScript.
Built on CSS scroll snap:
- scroll-snap-type on the <ul> (snap-x/snap-y + snap-mandatory/snap-proximity)
repos/mdn/.../css/reference/properties/scroll-snap-type
- scroll-snap-align on each <li> (snap-start/snap-center/snap-end)
repos/mdn/.../css/reference/properties/scroll-snap-align
- scroll-snap-stop: always (snap-always) so a fling can't skip an item
repos/mdn/.../css/reference/properties/scroll-snap-stop
Pattern: repos/web.dev/.../patterns/components/media-scroller
Named templates:
- "snap_list" — the <ul role="list"> scroll container (compose items in .Body)
- "snap_list_item" — one <li> with its scroll-snap-align
The root is a real <ul role="list"> + a keyboard tab stop (tabindex="0").
The .scrollbar-none helper lives in app/styles/input.css.
Usage:
{{template "snap_list" (dict "AriaLabel" "Photo strip"
"Body" (htmlSafe (printf "%s%s"
(... snap_list_item ...)
(... snap_list_item ...))))}}
*/}}
{{define "snap_list"}}
{{- $orientation := or .Orientation "horizontal" -}}
{{- $snap := or .Snap "mandatory" -}}
{{- $axis := "snap-x flex-row overflow-x-auto" -}}
{{- if eq $orientation "vertical"}}{{$axis = "snap-y flex-col overflow-y-auto"}}{{end -}}
{{- $strictness := "snap-mandatory" -}}
{{- if eq $snap "proximity"}}{{$strictness = "snap-proximity"}}{{end -}}
<ul data-slot="snap-list"
data-orientation="{{$orientation}}"
data-snap="{{$snap}}"
role="list"
tabindex="0"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 {{$axis}} {{$strictness}}">{{.Body}}</ul>
{{end}}
{{define "snap_list_item"}}
{{- $align := or .Align "start" -}}
{{- $alignClass := "snap-start" -}}
{{- if eq $align "center"}}{{$alignClass = "snap-center"}}{{else if eq $align "end"}}{{$alignClass = "snap-end"}}{{end -}}
<li data-slot="snap-list-item"
data-align="{{$align}}"
class="min-w-0 shrink-0 grow-0 {{$alignClass}}{{if .Stop}} snap-always{{end}}">{{.Body}}</li>
{{end}}
1. Save the file
Drop snap_list.ex into lib/my_app_web/components/.
2. Use it
<.snap_list aria-label="Photo strip">
<.snap_list_item><img src="/1.jpg" alt="…" /></.snap_list_item>
<.snap_list_item><img src="/2.jpg" alt="…" /></.snap_list_item>
</.snap_list>View source
defmodule ShadcnHtmx.Components.SnapList do
@moduledoc """
Snap List — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/snap-list.tsx. The bare, reusable CSS scroll-snap rail
(gallery strip / chip row / media shelf / date rail) — ZERO JavaScript. Two
function components: `snap_list` (the scroll container) and `snap_list_item`.
Built on CSS scroll snap (the platform owns scrolling + snapping):
- scroll-snap-type on the <ul> (snap-x/snap-y + snap-mandatory/snap-proximity)
repos/mdn/.../css/reference/properties/scroll-snap-type
- scroll-snap-align on each <li> (snap-start/snap-center/snap-end)
repos/mdn/.../css/reference/properties/scroll-snap-align
- scroll-snap-stop: always (snap-always) so a fling can't skip an item
repos/mdn/.../css/reference/properties/scroll-snap-stop
Pattern: repos/web.dev/.../patterns/components/media-scroller
The root is a real <ul role="list"> (Safari drops the implicit list role
once list-style is removed) and a keyboard tab stop (tabindex="0"). The
.scrollbar-none helper lives in app/styles/input.css.
## Examples
<.snap_list aria-label="Photo strip">
<.snap_list_item><img src="…" alt="…" /></.snap_list_item>
<.snap_list_item><img src="…" alt="…" /></.snap_list_item>
</.snap_list>
"""
use Phoenix.Component
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
attr :snap, :string, default: "mandatory", values: ~w(mandatory proximity)
attr :"aria-label", :string, default: nil
attr :"aria-labelledby", :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def snap_list(assigns) do
axis =
if assigns.orientation == "vertical",
do: "snap-y flex-col overflow-y-auto",
else: "snap-x flex-row overflow-x-auto"
strictness = if assigns.snap == "proximity", do: "snap-proximity", else: "snap-mandatory"
assigns = assign(assigns, axis: axis, strictness: strictness)
~H"""
<ul
data-slot="snap-list"
data-orientation={@orientation}
data-snap={@snap}
role="list"
tabindex="0"
aria-label={assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
class={[
"flex list-none scroll-smooth scrollbar-none rounded-lg outline-none",
"focus-visible:ring-[3px] focus-visible:ring-ring/50",
@axis,
@strictness,
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</ul>
"""
end
attr :align, :string, default: "start", values: ~w(start center end)
attr :stop, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def snap_list_item(assigns) do
align_class =
case assigns.align do
"center" -> "snap-center"
"end" -> "snap-end"
_ -> "snap-start"
end
assigns = assign(assigns, align_class: align_class)
~H"""
<li
data-slot="snap-list-item"
data-align={@align}
class={[
"min-w-0 shrink-0 grow-0",
@align_class,
@stop && "snap-always",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</li>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<ul data-slot="snap-list" data-orientation="horizontal" data-snap="mandatory"
role="list" tabindex="0" aria-label="Photo strip"
class="flex list-none scroll-smooth scrollbar-none snap-x flex-row overflow-x-auto snap-mandatory gap-4 …">
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">…</li>
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">…</li>
</ul>View source
<!--
shadcn-htmx — raw HTML snap-list snippet.
Mirrors registry/ui/snap-list.tsx. The bare, reusable CSS scroll-snap rail
(gallery strip / chip row / media shelf / date rail) — ZERO JavaScript. The
platform owns the scrolling (wheel / trackpad / touch swipe / the browser's
own keyboard scrolling of the focusable region) and the snapping.
Built on CSS scroll snap:
- scroll-snap-type on the <ul>: snap-x (axis) + snap-mandatory (strictness)
- scroll-snap-align on each <li>: snap-start / snap-center / snap-end
- scroll-snap-stop: always (snap-always) so a fling can't skip an item
Semantics: a real <ul role="list"> (Safari drops the implicit list role once
list-style is removed) that is a keyboard tab stop (tabindex="0") with a
visible focus ring, named via aria-label. Each entry is an <li>.
Required CSS theme variables: --ring. See app/styles/input.css. The
.scrollbar-none helper (hides the scrollbar; the region stays scrollable and
keyboard reachable) is defined there too.
Swap orientation by replacing the axis utilities on the <ul>:
horizontal: snap-x flex-row overflow-x-auto
vertical: snap-y flex-col overflow-y-auto
-->
<ul data-slot="snap-list"
data-orientation="horizontal"
data-snap="mandatory"
role="list"
tabindex="0"
aria-label="Photo strip"
class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 snap-x flex-row overflow-x-auto snap-mandatory gap-4 p-1">
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">
<div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">1</div>
</li>
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">
<div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">2</div>
</li>
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">
<div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">3</div>
</li>
<li data-slot="snap-list-item" data-align="start"
class="min-w-0 shrink-0 grow-0 snap-start basis-3/4">
<div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">4</div>
</li>
</ul>
Examples
Basic — a horizontal chip row
Scroll / swipe horizontally; each chip snaps to the leading edge. No buttons, no script — pure CSS scroll-snap.
The rail is a real <ul role="list"> with snap-x snap-mandatory and each <li> set to snap-start. Because the platform does the scrolling, mouse wheel, trackpad, touch swipe and the browser's own keyboard scrolling all work — the list is a tab stop ( tabindex="0") with a focus ring so keyboard users can reach and scroll it.
<SnapList ariaLabel="Filter tags" class="gap-3">
<SnapListItem><Chip label="All" /></SnapListItem>
<SnapListItem><Chip label="Photography" /></SnapListItem>
<SnapListItem><Chip label="Illustration" /></SnapListItem>
<SnapListItem><Chip label="3D & Motion" /></SnapListItem>
</SnapList>{{ snap_list_open(aria_label="Filter tags", extra_class="gap-3") }}
{% call(_) snap_list_item() %}<span class="…chip…">All</span>{% endcall %}
{% call(_) snap_list_item() %}<span class="…chip…">Photography</span>{% endcall %}
{{ snap_list_close() }}{{template "snap_list" (dict "AriaLabel" "Filter tags"
"Body" (htmlSafe (printf "%s%s"
"{{template \"snap_list_item\" (dict \"Body\" (htmlSafe \"…All…\"))}}"
"{{template \"snap_list_item\" (dict \"Body\" (htmlSafe \"…Photography…\"))}}")))}}<.snap_list aria-label="Filter tags" class="gap-3">
<.snap_list_item><span class="…chip…">All</span></.snap_list_item>
<.snap_list_item><span class="…chip…">Photography</span></.snap_list_item>
</.snap_list><div class="p-6">
<ul data-slot="snap-list" data-orientation="horizontal" data-snap="mandatory" role="list" tabindex="0" aria-label="Filter tags" class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 snap-x flex-row overflow-x-auto snap-mandatory gap-3 p-1">
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">All</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">Photography</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">Illustration</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">3D & Motion</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">Typography</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">Branding</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">UI / UX</span>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start">
<span class="inline-flex items-center rounded-full border bg-secondary px-4 py-1.5 text-sm font-medium text-secondary-foreground">Architecture</span>
</li>
</ul>
</div>Further reading
Media shelf — center align + snap stop
A one-up media shelf: items center on the snap line and snap-always stops the scroll on each one, so a fast fling can't skip past.
Set each item's align="center" to rest it in the middle of the rail, and stop to add scroll-snap-stop: always so the scroll must come to rest on each item rather than flinging over several. Sizing (here basis-3/4) is just Tailwind on the item — the snapping is unchanged.
<SnapList ariaLabel="Featured shots" class="gap-4">
<SnapListItem align="center" stop class="basis-3/4"><img … /></SnapListItem>
<SnapListItem align="center" stop class="basis-3/4"><img … /></SnapListItem>
<SnapListItem align="center" stop class="basis-3/4"><img … /></SnapListItem>
</SnapList>{{ snap_list_open(aria_label="Featured shots", extra_class="gap-4") }}
{% call(_) snap_list_item(align="center", stop=true, extra_class="basis-3/4") %}<img …>{% endcall %}
{% call(_) snap_list_item(align="center", stop=true, extra_class="basis-3/4") %}<img …>{% endcall %}
{{ snap_list_close() }}{{template "snap_list" (dict "AriaLabel" "Featured shots"
"Body" (htmlSafe "{{template \"snap_list_item\" (dict \"Align\" \"center\" \"Stop\" true \"Body\" (htmlSafe \"<img …>\"))}}"))}}<.snap_list aria-label="Featured shots" class="gap-4">
<.snap_list_item align="center" stop class="basis-3/4"><img … /></.snap_list_item>
<.snap_list_item align="center" stop class="basis-3/4"><img … /></.snap_list_item>
</.snap_list><div class="p-6">
<ul data-slot="snap-list" data-orientation="horizontal" data-snap="mandatory" role="list" tabindex="0" aria-label="Featured shots" class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 snap-x flex-row overflow-x-auto snap-mandatory gap-4 p-1">
<li data-slot="snap-list-item" data-align="center" class="min-w-0 shrink-0 grow-0 snap-center snap-always basis-3/4">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">1</div>
</li>
<li data-slot="snap-list-item" data-align="center" class="min-w-0 shrink-0 grow-0 snap-center snap-always basis-3/4">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">2</div>
</li>
<li data-slot="snap-list-item" data-align="center" class="min-w-0 shrink-0 grow-0 snap-center snap-always basis-3/4">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">3</div>
</li>
<li data-slot="snap-list-item" data-align="center" class="min-w-0 shrink-0 grow-0 snap-center snap-always basis-3/4">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">4</div>
</li>
</ul>
</div>Vertical rail
Switch the axis with orientation="vertical": the rail scrolls and snaps on the y axis instead.
orientation="vertical" swaps snap-x / overflow-x-auto for snap-y / overflow-y-auto and lays the list out as a column. The vertical scroll container needs a bounded height (here h-64) for there to be anything to scroll.
<SnapList orientation="vertical" ariaLabel="Stops" class="h-64 gap-3">
<SnapListItem class="basis-1/2"><img … /></SnapListItem>
<SnapListItem class="basis-1/2"><img … /></SnapListItem>
</SnapList>{{ snap_list_open(orientation="vertical", aria_label="Stops", extra_class="h-64 gap-3") }}
{% call(_) snap_list_item(extra_class="basis-1/2") %}<img …>{% endcall %}
{{ snap_list_close() }}{{template "snap_list" (dict "Orientation" "vertical" "AriaLabel" "Stops"
"Body" (htmlSafe "{{template \"snap_list_item\" (dict \"Body\" (htmlSafe \"<img …>\"))}}"))}}<.snap_list orientation="vertical" aria-label="Stops" class="h-64 gap-3">
<.snap_list_item class="basis-1/2"><img … /></.snap_list_item>
</.snap_list><div class="p-6">
<ul data-slot="snap-list" data-orientation="vertical" data-snap="mandatory" role="list" tabindex="0" aria-label="Stops" class="flex list-none scroll-smooth scrollbar-none rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 snap-y flex-col overflow-y-auto snap-mandatory h-64 max-w-xs gap-3 p-1">
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start basis-1/2">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">1</div>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start basis-1/2">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">2</div>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start basis-1/2">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">3</div>
</li>
<li data-slot="snap-list-item" data-align="start" class="min-w-0 shrink-0 grow-0 snap-start basis-1/2">
<div class="flex aspect-video w-full items-center justify-center rounded-lg border bg-muted text-3xl font-semibold text-muted-foreground">4</div>
</li>
</ul>
</div>Further reading
API Reference
<SnapList> / <SnapListItem>
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal"|"vertical" | "horizontal" | <SnapList>. Scroll/snap axis. Sets scroll-snap-type's axis (snap-x/snap-y) and lays the list out as a row or column. The vertical case needs a bounded height to scroll.MDNscroll-snap-type axis |
snap | "mandatory"|"proximity" | "mandatory" | <SnapList>. scroll-snap-type strictness. mandatory always rests on a snap point; proximity only snaps when a rest point is near (gentler on long content).MDNscroll-snap-type |
align | "start"|"center"|"end" | "start" | <SnapListItem>. Where the item snaps within the rail (scroll-snap-align). start for chip rows, center for one-up media shelves.MDNscroll-snap-align |
stop | boolean | false | <SnapListItem>. Sets scroll-snap-stop: always so a fast fling cannot skip past this item — the scroll must come to rest on it.MDNscroll-snap-stop |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |