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.json2. Use it
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
/** @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
{% 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
{# 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
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
{{/*
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
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
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
<!-- 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
<!--
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.
<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.
<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>Further reading
API Reference
<ScrollArea>
hx-*, data-*, aria-*, and standard attributes are forwarded onto the root via ...rest.
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
fade | boolean | true | 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() |
ariaLabel | string | — | 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 |
ariaLabelledby | string | — | Id of a visible element naming the scroll region. Alternative to ariaLabel.MDNaria-labelledby |
viewportClass | string | — | Extra Tailwind classes for the inner scrolling viewport (e.g. padding). The root gets class; the viewport gets viewportClass. |
children | Child | — | The scrollable content. Set a height/max-height on the root (via class, e.g. h-72) so the region actually constrains and overflows. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |