shshadcn-htmx

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.json

2. Use it

components/ui/snap-list.tsx
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
components/ui/snap-list.tsx
/** @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

templates/components/snap-list.html
{% 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
templates/components/snap-list.html
{# 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

components/snap-list.tmpl
{{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
components/snap-list.tmpl
{{/*
  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

lib/my_app_web/components/snap_list.ex
<.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
lib/my_app_web/components/snap_list.ex
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

snippets/snap-list.html
<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
snippets/snap-list.html
<!--
  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.

  • All
  • Photography
  • Illustration
  • 3D & Motion
  • Typography
  • Branding
  • UI / UX
  • Architecture
<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 &amp; 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>

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.

  • 1
  • 2
  • 3
  • 4
<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.

  • 1
  • 2
  • 3
  • 4
<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>

API Reference

<SnapList> / <SnapListItem>

PropTypeDefaultDescription
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
stopbooleanfalse
<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
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference