shshadcn-htmx

Components

Scroll Area

A constrained-overflow region with a themed scrollbar and optional fade masks that appear only while more content can scroll into view. Scrolling is fully native — keyboard, wheel, trackpad, and touch all work with zero JavaScript. No Radix-style scrollbar reimplementation; the masks are driven by CSS @container scroll-state().

Installation

One file per stack — no npm package, no build step required. Use the shadcn CLI for JSX projects, or copy the source straight into your template directory.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/scroll-area.json

2. Use it

components/ui/scroll-area.tsx
import { ScrollArea } from "@/components/ui/scroll-area"

<ScrollArea aria-label="Changelog" class="h-72 w-full max-w-sm border">
  <div class="p-4 text-sm">…lots of content…</div>
</ScrollArea>
Or copy the source manually
components/ui/scroll-area.tsx
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Scroll Area — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A constrained-overflow region: content taller (or wider) than the box
// scrolls natively, with a themed scrollbar and optional fade masks that
// appear at the start / end edges only while there is more content to
// scroll to in that direction.
//
// shadcn/ui's upstream ScrollArea wraps Radix's ScrollArea, which hides the
// native scrollbar and re-implements the thumb + track + drag handling in
// JavaScript. We do NOT copy that (AGENTS.md rule 4: no emulating platform
// features). The browser already ships native scrolling, a keyboard-operable
// scroll container, and — now — themeable scrollbars and scroll-state queries.
// So this component is ZERO JavaScript:
//   Upstream (anatomy only):
//     repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/scroll-area.tsx
//
// Built on:
//   - CSS overflow — `overflow-y: auto` / `overflow-x: auto` makes the
//     viewport a scroll container that shows scrollbars only when needed and
//     respects the user's OS preference. A scroll region must be keyboard
//     operable, so the viewport carries tabindex="0" + role="region" + an
//     accessible name (aria-labelledby / aria-label). This is the exact
//     contract from the web.dev "Overflow" lesson (Scrolling and
//     accessibility): repos/web.dev/src/site/content/en/learn/css/overflow/index.md
//   - CSS scrollbar-width / scrollbar-color — the standard, cross-browser way
//     to theme a scrollbar (Tailwind v4 ships `scrollbar-thin` /
//     `scrollbar-thumb-*` / `scrollbar-track-*` utilities for them; verified
//     repos/tailwindcss/packages/tailwindcss/src/utilities.ts:2230-2255).
//   - CSS @container scroll-state(scrollable: <edge>) — toggles the fade
//     masks. The viewport is a scroll-state query container
//     (container-type: scroll-state); the masks are REAL `position: sticky`
//     CHILD elements of the viewport whose opacity is driven by whether the
//     container can still be scrolled towards that edge. (Verified in Chromium
//     136: the query styles DESCENDANTS of the scroll container — a
//     pseudo-element of the container itself is not matched, so the masks must
//     be real children.) Negative margins keep the sticky masks from adding to
//     the scroll length, so they overlay rather than push content:
//       repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
//         (scrollable descriptor, lines 224-261)
//       repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md
//         ("Using `scrollable` queries")
//
// The container-type, the sticky-mask geometry, and the @container
// scroll-state(...) opacity rules live in app/styles/input.css, scoped to
// [data-slot="scroll-area"] (Tailwind has no utility for scroll-state
// container queries). Everything else is utilities.

export type ScrollAreaOrientation = "vertical" | "horizontal" | "both"

// Root is the positioning context + clips the rounded corners.
const root = "relative overflow-hidden rounded-md"

// The scroll viewport. tabindex/role/name are set on the element so keyboard
// users get a tab stop + arrow-key scrolling (web.dev overflow a11y). The
// scrollbar utilities theme it with the standard scrollbar-width/-color
// properties. `data-scroll-area-viewport` + the data-fade flag let the CSS in
// input.css set container-type:scroll-state for this instance.
const viewportBase =
  "size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent " +
  "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit]"

const overflowAxis: Record<ScrollAreaOrientation, string> = {
  vertical: "overflow-y-auto overflow-x-hidden",
  horizontal: "overflow-x-auto overflow-y-hidden",
  both: "overflow-auto",
}

type ScrollAreaProps = {
  // Which axis scrolls. Defaults to vertical (the common reading list / panel).
  orientation?: ScrollAreaOrientation
  // Show start/end (top/bottom or left/right) fade masks that fade in only
  // while more content can scroll into view in that direction. Default true.
  fade?: boolean
  // Accessible name for the scroll region. One of these is required for the
  // region to be announced to assistive tech (web.dev overflow a11y contract).
  ariaLabel?: string
  ariaLabelledby?: string
  // Extra classes for the ROOT. Set a height/max-height here (or on a wrapper)
  // so the region actually constrains its content, e.g. class="h-72".
  class?: ClassValue
  // Extra classes for the inner viewport (rarely needed; e.g. padding).
  viewportClass?: ClassValue
  id?: string
  children?: Child
  // Forward hx-*, data-*, aria-*, and standard attributes onto the root.
  [key: string]: unknown
}

// A single fade mask: a sticky, pointer-transparent child pinned to one edge.
// The gradient direction + which scroll-state query lights it up come from the
// CSS in input.css (keyed on the root's data-orientation + this data-edge).
function ScrollAreaFade(props: { edge: "start" | "end" }) {
  return (
    <div
      data-slot="scroll-area-fade"
      data-edge={props.edge}
      aria-hidden="true"
      class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"
    />
  )
}

export function ScrollArea(props: ScrollAreaProps) {
  const {
    orientation = "vertical",
    fade = true,
    ariaLabel,
    ariaLabelledby,
    class: className,
    viewportClass,
    id,
    children,
    ...rest
  } = props

  return (
    <div id={id} data-slot="scroll-area" data-orientation={orientation} class={cn(root, className)} {...rest}>
      <div
        data-slot="scroll-area-viewport"
        data-scroll-area-viewport
        data-fade={fade ? "true" : undefined}
        role="region"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        tabindex={0}
        class={cn(viewportBase, overflowAxis[orientation], viewportClass)}
      >
        {fade && <ScrollAreaFade edge="start" />}
        {children}
        {fade && <ScrollAreaFade edge="end" />}
      </div>
    </div>
  )
}

1. Save the file

Copy scroll-area.html into templates/components/.

2. Use it

templates/components/scroll-area.html
{% from "components/scroll-area.html" import scroll_area %}

{% call scroll_area(aria_label="Changelog", extra_class="h-72 w-full max-w-sm border") %}
  <div class="p-4 text-sm">…lots of content…</div>
{% endcall %}
View source
templates/components/scroll-area.html
{# Scroll Area macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/scroll-area.tsx so a Python/Flask/FastAPI/Django
   project renders the same markup our docs site renders.

   A constrained-overflow region: content taller (or wider) than the box
   scrolls NATIVELY, with a themed scrollbar and optional top/bottom fade
   masks that appear only while more content can scroll in that direction.
   Zero JavaScript — the masks are driven by CSS @container scroll-state().

   Built on:
     - CSS overflow + the web.dev a11y contract (a scroll region needs
       tabindex="0" + role="region" + an accessible name):
       repos/web.dev/src/site/content/en/learn/css/overflow/index.md
     - Tailwind v4 scrollbar-thin / scrollbar-thumb-* / scrollbar-track-*
       (standard scrollbar-width / scrollbar-color):
       repos/tailwindcss/packages/tailwindcss/src/utilities.ts:2230-2255
     - CSS @container scroll-state(scrollable: top|bottom) for the fade masks:
       repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
       repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md
   The container-type + fade rules live in app/styles/input.css, scoped to
   [data-slot="scroll-area"].

   Usage:
     {% from "components/scroll-area.html" import scroll_area %}
     {% call scroll_area(aria_label="Changelog", extra_class="h-72") %}
       <div class="p-4 text-sm">…lots of content…</div>
     {% endcall %} #}

{% macro scroll_area(
    orientation="vertical",
    fade=true,
    aria_label=none,
    aria_labelledby=none,
    id=none,
    viewport_class="",
    extra_class="",
    **attrs
) %}
{%- set overflow_axis = {
    "vertical": "overflow-y-auto overflow-x-hidden",
    "horizontal": "overflow-x-auto overflow-y-hidden",
    "both": "overflow-auto"
} -%}
<div
  {%- if id %} id="{{ id }}"{% endif %}
  data-slot="scroll-area"
  data-orientation="{{ orientation }}"
  class="relative overflow-hidden rounded-md {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
  <div
    data-slot="scroll-area-viewport"
    data-scroll-area-viewport
    {%- if fade %} data-fade="true"{% endif %}
    role="region"
    {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
    {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
    tabindex="0"
    class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] {{ overflow_axis[orientation] }} {{ viewport_class }}">
    {%- if fade %}<div data-slot="scroll-area-fade" data-edge="start" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>{% endif -%}
    {{ caller() }}
    {%- if fade %}<div data-slot="scroll-area-fade" data-edge="end" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>{% endif -%}
  </div>
</div>
{% endmacro %}

1. Save the file

Add scroll-area.tmpl alongside your templates.

2. Use it

components/scroll-area.tmpl
tpl.ExecuteTemplate(w, "scroll-area", map[string]any{
    "AriaLabel": "Changelog",
    "Class":     "h-72 w-full max-w-sm border",
    "Body":      template.HTML(`<div class="p-4 text-sm">…</div>`),
})
View source
components/scroll-area.tmpl
{{/*
  Scroll Area template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/scroll-area.tsx for Go projects using html/template.

  A constrained-overflow region: content taller (or wider) than the box
  scrolls NATIVELY, with a themed scrollbar and optional top/bottom fade
  masks that appear only while more content can scroll in that direction.
  Zero JavaScript — the masks are driven by CSS @container scroll-state().

  Built on:
    - CSS overflow + the web.dev a11y contract (a scroll region needs
      tabindex="0" + role="region" + an accessible name):
      repos/web.dev/src/site/content/en/learn/css/overflow/index.md
    - Tailwind v4 scrollbar-thin / scrollbar-thumb-* / scrollbar-track-*:
      repos/tailwindcss/packages/tailwindcss/src/utilities.ts:2230-2255
    - CSS @container scroll-state(scrollable: top|bottom) for the fade masks:
      repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
      repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md
  The container-type + fade rules live in app/styles/input.css, scoped to
  [data-slot="scroll-area"].

      type ScrollAreaArgs struct {
          Orientation   string        // "vertical" (default) | "horizontal" | "both"
          Fade          bool          // top/bottom fade masks (default true via NoFade inversion below)
          NoFade        bool          // set true to turn the fade masks off
          AriaLabel     string
          AriaLabelledby string
          ID            string
          Class         string        // extra classes on the root (set a height, e.g. "h-72")
          ViewportClass string        // extra classes on the inner viewport
          Body          template.HTML // the scrollable content
          Attrs         map[string]string
      }
*/}}

{{define "scroll-area"}}
{{- $orientation := or .Orientation "vertical" -}}
{{- $overflow := "overflow-y-auto overflow-x-hidden" -}}
{{- if eq $orientation "horizontal" -}}{{- $overflow = "overflow-x-auto overflow-y-hidden" -}}{{- else if eq $orientation "both" -}}{{- $overflow = "overflow-auto" -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}" {{end}}data-slot="scroll-area" data-orientation="{{$orientation}}" class="relative overflow-hidden rounded-md {{.Class}}"
  {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport
    {{- if not .NoFade}} data-fade="true"{{end}}
    role="region"
    {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
    {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
    tabindex="0"
    class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] {{$overflow}} {{.ViewportClass}}">
    {{- if not .NoFade}}<div data-slot="scroll-area-fade" data-edge="start" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>{{end}}
    {{- htmlSafe .Body}}
    {{- if not .NoFade}}<div data-slot="scroll-area-fade" data-edge="end" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>{{end}}
  </div>
</div>
{{end}}

1. Save the file

Drop scroll_area.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/scroll_area.ex
alias ShadcnHtmx.Components.ScrollArea

<ScrollArea.scroll_area aria-label="Changelog" class="h-72 w-full max-w-sm border">
  <div class="p-4 text-sm">…lots of content…</div>
</ScrollArea.scroll_area>
View source
lib/my_app_web/components/scroll_area.ex
defmodule ShadcnHtmx.Components.ScrollArea do
  @moduledoc """
  Scroll Area — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/scroll-area.tsx.

  A constrained-overflow region: content taller (or wider) than the box
  scrolls NATIVELY, with a themed scrollbar and optional top/bottom fade
  masks that appear only while more content can scroll in that direction.
  Zero JavaScript — the masks are driven by CSS `@container scroll-state()`.

  Built on:
    * CSS overflow + the web.dev a11y contract (a scroll region needs
      `tabindex="0"` + `role="region"` + an accessible name):
      repos/web.dev/src/site/content/en/learn/css/overflow/index.md
    * Tailwind v4 `scrollbar-thin` / `scrollbar-thumb-*` / `scrollbar-track-*`
      (standard `scrollbar-width` / `scrollbar-color`):
      repos/tailwindcss/packages/tailwindcss/src/utilities.ts:2230-2255
    * CSS `@container scroll-state(scrollable: top|bottom)` for the fade masks:
      repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
      repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md

  The container-type + fade rules live in app/styles/input.css, scoped to
  `[data-slot="scroll-area"]`.

  ## Examples

      <.scroll_area aria-label="Changelog" class="h-72">
        <div class="p-4 text-sm">…lots of content…</div>
      </.scroll_area>

      <.scroll_area orientation="horizontal" fade={false} class="w-96">
        <div class="flex gap-3 p-4">…wide row…</div>
      </.scroll_area>
  """

  use Phoenix.Component

  @root "relative overflow-hidden rounded-md"

  @viewport_base "size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent " <>
                   "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit]"

  attr :orientation, :string, default: "vertical", values: ~w(vertical horizontal both)
  attr :fade, :boolean, default: true
  attr :class, :string, default: nil
  attr :viewport_class, :string, default: nil

  attr :rest, :global,
    include: ~w(id aria-label aria-labelledby)

  slot :inner_block, required: true

  def scroll_area(assigns) do
    assigns =
      assigns
      |> assign(:root, @root)
      |> assign(:viewport_base, @viewport_base)
      |> assign(:overflow, overflow_axis(assigns.orientation))

    ~H"""
    <div
      data-slot="scroll-area"
      data-orientation={@orientation}
      class={[@root, @class]}
      {@rest}
    >
      <div
        data-slot="scroll-area-viewport"
        data-scroll-area-viewport
        data-fade={if @fade, do: "true", else: nil}
        role="region"
        tabindex="0"
        class={[@viewport_base, @overflow, @viewport_class]}
      >
        <div
          :if={@fade}
          data-slot="scroll-area-fade"
          data-edge="start"
          aria-hidden="true"
          class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"
        />
        {render_slot(@inner_block)}
        <div
          :if={@fade}
          data-slot="scroll-area-fade"
          data-edge="end"
          aria-hidden="true"
          class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"
        />
      </div>
    </div>
    """
  end

  defp overflow_axis("horizontal"), do: "overflow-x-auto overflow-y-hidden"
  defp overflow_axis("both"), do: "overflow-auto"
  defp overflow_axis(_vertical), do: "overflow-y-auto overflow-x-hidden"
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/scroll-area.html
<!-- Set a height on the ROOT so the region constrains its content. -->
<div data-slot="scroll-area" data-orientation="vertical"
     class="relative overflow-hidden rounded-md border h-72 w-full max-w-sm">
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport data-fade="true"
       role="region" aria-label="Changelog" tabindex="0"
       class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto overflow-x-hidden rounded-[inherit]">
    <div class="p-4 text-sm">…lots of content…</div>
  </div>
</div>
View source
snippets/scroll-area.html
<!--
  shadcn-htmx — raw HTML scroll-area snippets.

  A constrained-overflow region: content taller (or wider) than the box
  scrolls NATIVELY, with a themed scrollbar and optional start/end fade masks
  that appear only while more content can scroll in that direction.
  Zero JavaScript — the masks are driven by CSS @container scroll-state().

    web.dev overflow a11y: repos/web.dev/src/site/content/en/learn/css/overflow/index.md
    Tailwind scrollbar utils: repos/tailwindcss/packages/tailwindcss/src/utilities.ts:2230-2255
    @container scroll-state: repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
                             repos/mdn/files/en-us/web/css/guides/conditional_rules/container_scroll-state_queries/index.md

  Requirements:
    1. Tailwind CSS v4 (or the Play CDN for experiments). The scrollbar-thin /
       scrollbar-thumb-border / scrollbar-track-transparent utilities theme the
       native scrollbar via standard scrollbar-width / scrollbar-color.
    2. The shadcn theme tokens (--border, --ring, --background, …) — copy the
       :root / .dark blocks from app/styles/input.css.
    3. For the fade masks, add this rule to your stylesheet. The masks are REAL
       sticky child elements (a container's own ::before/::after are NOT matched
       by scroll-state queries — verified in Chromium). Negative margins keep
       them overlaying rather than pushing content:

         [data-slot="scroll-area"] [data-scroll-area-viewport][data-fade] {
           container-type: scroll-state;
         }
         [data-slot="scroll-area"] [data-slot="scroll-area-fade"] {
           position: sticky; z-index: 1; pointer-events: none;
           opacity: 0; transition: opacity 0.2s ease;
         }
         /* Vertical (default) + both: full-width bars pinned top / bottom. */
         [data-slot="scroll-area"]:not([data-orientation="horizontal"]) [data-slot="scroll-area-fade"] {
           display: block; left: 0; width: 100%; height: 2rem;
         }
         [data-slot="scroll-area"]:not([data-orientation="horizontal"]) [data-slot="scroll-area-fade"][data-edge="start"] {
           top: 0; margin-bottom: -2rem;
           background: linear-gradient(to bottom, var(--background), transparent);
         }
         [data-slot="scroll-area"]:not([data-orientation="horizontal"]) [data-slot="scroll-area-fade"][data-edge="end"] {
           bottom: 0; margin-top: -2rem;
           background: linear-gradient(to top, var(--background), transparent);
         }
         /* Horizontal: full-height bars pinned left / right (inline-block so
            they sit inline with the scrolling row). */
         [data-slot="scroll-area"][data-orientation="horizontal"] [data-slot="scroll-area-fade"] {
           display: inline-block; top: 0; height: 100%; width: 2rem;
         }
         [data-slot="scroll-area"][data-orientation="horizontal"] [data-slot="scroll-area-fade"][data-edge="start"] {
           left: 0; margin-right: -2rem;
           background: linear-gradient(to right, var(--background), transparent);
         }
         [data-slot="scroll-area"][data-orientation="horizontal"] [data-slot="scroll-area-fade"][data-edge="end"] {
           right: 0; margin-left: -2rem;
           background: linear-gradient(to left, var(--background), transparent);
         }
         @container scroll-state(scrollable: top) {
           [data-slot="scroll-area"]:not([data-orientation="horizontal"]) [data-slot="scroll-area-fade"][data-edge="start"] { opacity: 1; }
         }
         @container scroll-state(scrollable: bottom) {
           [data-slot="scroll-area"]:not([data-orientation="horizontal"]) [data-slot="scroll-area-fade"][data-edge="end"] { opacity: 1; }
         }
         @container scroll-state(scrollable: left) {
           [data-slot="scroll-area"][data-orientation="horizontal"] [data-slot="scroll-area-fade"][data-edge="start"] { opacity: 1; }
         }
         @container scroll-state(scrollable: right) {
           [data-slot="scroll-area"][data-orientation="horizontal"] [data-slot="scroll-area-fade"][data-edge="end"] { opacity: 1; }
         }

  A scroll region must be keyboard operable: keep tabindex="0", role="region",
  and an accessible name (aria-label / aria-labelledby) on the viewport.
  Set a height on the ROOT (e.g. h-72) so the region actually constrains.
-->

<!-- Vertical scroll area with top/bottom fade masks -->
<div data-slot="scroll-area" data-orientation="vertical"
     class="relative overflow-hidden rounded-md border h-72 w-full max-w-sm">
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport data-fade="true"
       role="region" aria-label="Release notes" tabindex="0"
       class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] overflow-y-auto overflow-x-hidden">
    <div data-slot="scroll-area-fade" data-edge="start" aria-hidden="true"
         class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>
    <div class="space-y-3 p-4 text-sm">
      <p>Scroll down — the bottom fade hints there is more. Once you reach the
        end it disappears; the top fade appears as soon as you leave the top.</p>
      <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
      <p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
      <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
      <p>Duis aute irure dolor in reprehenderit in voluptate velit esse.</p>
      <p>Excepteur sint occaecat cupidatat non proident, sunt in culpa.</p>
      <p>Qui officia deserunt mollit anim id est laborum.</p>
    </div>
    <div data-slot="scroll-area-fade" data-edge="end" aria-hidden="true"
         class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200"></div>
  </div>
</div>

<!-- Horizontal scroll area, no fade masks (just the themed scrollbar) -->
<div data-slot="scroll-area" data-orientation="horizontal"
     class="relative overflow-hidden rounded-md border w-full max-w-md">
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport
       role="region" aria-label="Tags" tabindex="0"
       class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] overflow-x-auto overflow-y-hidden">
    <div class="flex w-max gap-3 p-4 text-sm">
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">design</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">engineering</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">marketing</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">operations</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">research</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">support</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">finance</span>
    </div>
  </div>
</div>

Examples

Vertical, with fade masks

Scroll the panel. The bottom fade hints there is more; the top fade appears once you leave the top edge.

The viewport is a native scroll container (overflow-y: auto), so wheel, trackpad, touch, and the browser's own arrow-key scrolling all work with no JavaScript. The fade masks are pure CSS: the viewport is a scroll-state query container, and @container scroll-state(scrollable: top | bottom) fades each mask in only while there is still content to scroll to in that direction. Give the region a height on the root and an accessible name so screen-reader users can reach and identify it.

v4.0.0

Native scrolling, themed scrollbar, CSS-only fade masks.

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna.

Ut enim ad minim veniam, quis nostrud exercitation ullamco.

Duis aute irure dolor in reprehenderit in voluptate velit.

Excepteur sint occaecat cupidatat non proident, sunt in culpa.

Qui officia deserunt mollit anim id est laborum.

— end of changelog —

<ScrollArea aria-label="Release notes" class="h-64 w-full max-w-sm border">
  <div class="space-y-3 p-4 text-sm">…lots of content…</div>
</ScrollArea>
{% call scroll_area(aria_label="Release notes", extra_class="h-64 w-full max-w-sm border") %}
  <div class="space-y-3 p-4 text-sm">…lots of content…</div>
{% endcall %}
{{template "scroll-area" (dict
  "AriaLabel" "Release notes"
  "Class" "h-64 w-full max-w-sm border"
  "Body" (htmlSafe "<div class=\"space-y-3 p-4 text-sm\">…</div>")
)}}
<ScrollArea.scroll_area aria-label="Release notes" class="h-64 w-full max-w-sm border">
  <div class="space-y-3 p-4 text-sm">…lots of content…</div>
</ScrollArea.scroll_area>
<div data-slot="scroll-area" data-orientation="vertical" class="relative overflow-hidden rounded-md h-64 w-full max-w-sm border">
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport="true" data-fade="true" role="region" aria-label="Release notes" tabindex="0" class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] overflow-y-auto overflow-x-hidden">
    <div data-slot="scroll-area-fade" data-edge="start" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200">
    </div>
    <div class="space-y-3 p-4 text-sm">
      <p class="font-medium text-foreground">v4.0.0</p>
      <p>Native scrolling, themed scrollbar, CSS-only fade masks.</p>
      <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
      <p>Sed do eiusmod tempor incididunt ut labore et dolore magna.</p>
      <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco.</p>
      <p>Duis aute irure dolor in reprehenderit in voluptate velit.</p>
      <p>Excepteur sint occaecat cupidatat non proident, sunt in culpa.</p>
      <p>Qui officia deserunt mollit anim id est laborum.</p>
      <p class="text-muted-foreground">— end of changelog —</p>
    </div>
    <div data-slot="scroll-area-fade" data-edge="end" aria-hidden="true" class="pointer-events-none sticky z-[1] opacity-0 transition-opacity duration-200">
    </div>
  </div>
</div>

Horizontal, no fade

Switch the overflow axis to horizontal and drop the masks. The themed scrollbar stays.

Set orientation="horizontal" to scroll along the inline axis, and fade={false} when the edge cue would be noise (a chip row reads fine without it). The scrollbar is themed with the standard scrollbar-width and scrollbar-color properties (Tailwind's scrollbar-thin utilities) — no custom thumb to drag, just the OS scrollbar tinted to match the theme.

designengineeringmarketingoperationsresearchsupportfinance
<ScrollArea orientation="horizontal" fade={false} aria-label="Categories" class="w-full max-w-md border">
  <div class="flex w-max gap-3 p-4 text-sm">…chips…</div>
</ScrollArea>
{% call scroll_area(orientation="horizontal", fade=false, aria_label="Categories", extra_class="w-full max-w-md border") %}
  <div class="flex w-max gap-3 p-4 text-sm">…chips…</div>
{% endcall %}
{{template "scroll-area" (dict
  "Orientation" "horizontal" "NoFade" true
  "AriaLabel" "Categories" "Class" "w-full max-w-md border"
  "Body" (htmlSafe "<div class=\"flex w-max gap-3 p-4 text-sm\">…</div>")
)}}
<ScrollArea.scroll_area orientation="horizontal" fade={false} aria-label="Categories" class="w-full max-w-md border">
  <div class="flex w-max gap-3 p-4 text-sm">…chips…</div>
</ScrollArea.scroll_area>
<div data-slot="scroll-area" data-orientation="horizontal" class="relative overflow-hidden rounded-md w-full max-w-md border">
  <div data-slot="scroll-area-viewport" data-scroll-area-viewport="true" role="region" aria-label="Categories" tabindex="0" class="size-full scroll-smooth scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-[inherit] overflow-x-auto overflow-y-hidden">
    <div class="flex w-max gap-3 p-4 text-sm">
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">design</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">engineering</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">marketing</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">operations</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">research</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">support</span>
      <span class="rounded-full border px-3 py-1 whitespace-nowrap">finance</span>
    </div>
  </div>
</div>

API Reference

<ScrollArea>

hx-*, data-*, aria-*, and standard attributes are forwarded onto the root via ...rest.

PropTypeDefaultDescription
orientation"vertical"|"horizontal"|"both""vertical"
Which axis scrolls. vertical = overflow-y, horizontal = overflow-x, both = overflow both ways. Also selects which scroll-state edges drive the fade masks.MDNoverflow
fadebooleantrue
Render start/end fade masks (top+bottom for vertical, left+right for horizontal) that fade in only while content can still scroll towards that edge. Driven by CSS @container scroll-state() — no JS.MDN@container scroll-state()
ariaLabelstring
Accessible name for the scroll region (rendered on the viewport, which is role=region + tabindex=0). Provide this or ariaLabelledby so assistive tech can reach and identify the scrollable area.MDNaria-label
ariaLabelledbystring
Id of a visible element naming the scroll region. Alternative to ariaLabel.MDNaria-labelledby
viewportClassstring
Extra Tailwind classes for the inner scrolling viewport (e.g. padding). The root gets class; the viewport gets viewportClass.
childrenChild
The scrollable content. Set a height/max-height on the root (via class, e.g. h-72) so the region actually constrains and overflows.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference