Components
Scroll Progress
A reading-position bar whose fill tracks how far you've scrolled — a pure animation-timeline: scroll() animation with zero JavaScript and no scroll listeners. Unlike Progress, there's no value to set — the browser drives the fill from the scroll position itself.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/scroll-progress.json2. Use it
import { ScrollProgress } from "@/components/ui/scroll-progress"
// Drop it once near the top of your layout — it pins itself to the viewport.
<ScrollProgress /> // tracks the whole page
<ScrollProgress position="bottom" /> // pinned to the bottom edge
<ScrollProgress timeline="--article" /> // driven by a named scrollerOr copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Scroll Progress — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A reading-position indicator: a thin bar whose fill tracks how far the user
// has scrolled the page (or a named scroller). This is DISTINCT from Progress
// (registry/ui/progress.tsx), which is value-driven via aria-valuenow — here
// there is no value, just scroll position the user already controls.
//
// Built entirely on CSS scroll-driven animations — ZERO JavaScript, no scroll
// listeners. The platform progresses a keyframe animation along a scroll
// progress timeline instead of the document's time-based timeline:
// - animation-timeline: scroll(root block) ties the fill's animation to the
// root scroller's block axis (i.e. the page's vertical scrollbar):
// repos/mdn/files/en-us/web/css/reference/properties/animation-timeline/scroll/index.md
// repos/mdn/files/en-us/web/css/reference/properties/animation-timeline/index.md
// - a named timeline (scroll-timeline-name on the scroller, referenced as a
// <dashed-ident> in animation-timeline) drives progress from any scroll
// container, not just the page:
// repos/mdn/files/en-us/web/css/reference/properties/scroll-timeline-name/index.md
// - overview of the module:
// repos/mdn/files/en-us/web/css/guides/scroll-driven_animations/index.md
//
// The fill animation (scn-scroll-progress: scaleX(0) -> scaleX(1)) lives in
// app/styles/input.css alongside the other scn-* keyframes. animation-timeline
// is a reset-only part of the `animation` shorthand, so the keyframe + timeline
// are applied from CSS keyed on data-slot rather than a Tailwind animate-[…]
// utility (which would reset the timeline back to auto). The CSS also pins a
// prefers-reduced-motion fallback (full bar, no animation) per:
// repos/mdn/files/en-us/web/css/reference/at-rules/@media/prefers-reduced-motion/index.md
//
// Accessibility: this is a decorative affordance that mirrors the scroll
// position the user already commands — there is no value to announce and we
// can't update aria-valuenow without scroll listeners (which we don't ship).
// So the bar is aria-hidden; it adds no noise to the accessibility tree.
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-hidden/
export type ScrollProgressPosition = "top" | "bottom"
// The track is fixed to the viewport edge and spans the full inline size. It
// sits above page content (z-50) and never intercepts pointer events.
const trackBase =
"pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15"
const positions: Record<ScrollProgressPosition, string> = {
top: "top-0",
bottom: "bottom-0",
}
// The fill grows from the inline-start edge. transform-origin + the scaleX
// keyframe is set in input.css (keyed on data-slot="scroll-progress-indicator").
const fillBase = "h-full w-full origin-left bg-primary"
type ScrollProgressProps = {
// Which viewport edge the bar pins to. top (default) is the classic reading
// bar under a sticky header; bottom hugs the foot of the page.
position?: ScrollProgressPosition
// Drive the bar from a named scroller instead of the page. Set the matching
// scroll-timeline-name (a --dashed-ident) on that scroll container's CSS;
// the bar then references it via animation-timeline. Omit to track the page
// root (scroll(root block)).
timeline?: string
class?: ClassValue
id?: string
// htmx + arbitrary attributes ride onto the root track element.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}
export function ScrollProgress(props: ScrollProgressProps) {
const {
position = "top",
timeline,
class: className,
id,
...rest
} = props as ScrollProgressProps & Record<string, any>
return (
<div
id={id}
data-slot="scroll-progress"
data-position={position}
aria-hidden="true"
class={cn(trackBase, positions[position as ScrollProgressPosition], className)}
{...rest}
>
<div
data-slot="scroll-progress-indicator"
// A named timeline overrides the default page-root timeline. Set as an
// inline property so it cascades after the keyframe (which is reset-only
// inside the `animation` shorthand).
style={timeline ? `animation-timeline: ${timeline}` : undefined}
class={fillBase}
/>
</div>
)
}
1. Save the file
Copy scroll-progress.html into templates/components/.
2. Use it
{% from "components/scroll-progress.html" import scroll_progress %}
{{ scroll_progress() }} {# tracks the page #}
{{ scroll_progress(position="bottom") }}
{{ scroll_progress(timeline="--article") }} {# named scroller #}View source
{# Scroll Progress macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/scroll-progress.tsx. A reading-position bar whose fill
tracks scroll position — ZERO JavaScript, pure CSS scroll-driven animation.
Built on CSS scroll-driven animations:
- animation-timeline: scroll(root block) ties the fill to the page scroller
repos/mdn/.../css/reference/properties/animation-timeline/scroll
repos/mdn/.../css/reference/properties/animation-timeline
- a named timeline (scroll-timeline-name on the scroller) drives it from
any scroll container
repos/mdn/.../css/reference/properties/scroll-timeline-name
- module overview: repos/mdn/.../css/guides/scroll-driven_animations
The fill keyframe (scn-scroll-progress: scaleX(0)->scaleX(1)) + the default
animation-timeline live in app/styles/input.css keyed on data-slot, plus a
prefers-reduced-motion fallback. The bar is decorative (aria-hidden) — it
mirrors scroll position the user already controls.
Usage:
{% from "components/scroll-progress.html" import scroll_progress %}
{{ scroll_progress() }} {# tracks the page #}
{{ scroll_progress(timeline="--article") }} {# named scroller #} #}
{% macro scroll_progress(position="top", timeline=none, id=none, extra_class="", attrs={}) -%}
{%- set edge = "top-0" if position == "top" else "bottom-0" -%}
<div {% if id %}id="{{ id }}"{% endif %}
data-slot="scroll-progress"
data-position="{{ position }}"
aria-hidden="true"
class="pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15 {{ edge }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
<div data-slot="scroll-progress-indicator"
{% if timeline %}style="animation-timeline: {{ timeline }}"{% endif %}
class="h-full w-full origin-left bg-primary"></div>
</div>
{%- endmacro %}
1. Save the file
Add scroll-progress.tmpl alongside your templates.
2. Use it
{{template "scroll-progress" (dict)}}
{{template "scroll-progress" (dict "Position" "bottom")}}
{{template "scroll-progress" (dict "Timeline" "--article")}}View source
{{/* Scroll Progress template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/scroll-progress.tsx. A reading-position bar whose fill
tracks scroll position — ZERO JavaScript, pure CSS scroll-driven animation.
Built on CSS scroll-driven animations:
- animation-timeline: scroll(root block) ties the fill to the page scroller
repos/mdn/.../css/reference/properties/animation-timeline/scroll
- a named timeline (scroll-timeline-name on the scroller) drives it from
any scroll container
repos/mdn/.../css/reference/properties/scroll-timeline-name
The fill keyframe (scn-scroll-progress) + default animation-timeline live in
app/styles/input.css keyed on data-slot, with a prefers-reduced-motion
fallback. The bar is decorative (aria-hidden). */}}
{{define "scroll-progress"}}
{{- $position := or .Position "top" -}}
{{- $edge := "top-0" -}}{{- if eq $position "bottom" -}}{{- $edge = "bottom-0" -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="scroll-progress"
data-position="{{$position}}"
aria-hidden="true"
class="pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15 {{$edge}}">
<div data-slot="scroll-progress-indicator"
{{if .Timeline}}style="animation-timeline: {{.Timeline}}"{{end}}
class="h-full w-full origin-left bg-primary"></div>
</div>
{{end}}
1. Save the file
Drop scroll_progress.ex into lib/my_app_web/components/.
2. Use it
<.scroll_progress />
<.scroll_progress position="bottom" />
<.scroll_progress timeline="--article" />View source
defmodule ShadcnHtmx.Components.ScrollProgress do
@moduledoc """
Scroll Progress — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/scroll-progress.tsx. A reading-position bar whose fill
tracks how far the user has scrolled the page (or a named scroller). This is
DISTINCT from Progress (value-driven via aria-valuenow) — here there is no
value, just scroll position the user already controls.
Built entirely on CSS scroll-driven animations — ZERO JavaScript, no scroll
listeners:
- animation-timeline: scroll(root block) ties the fill to the page scroller
repos/mdn/.../css/reference/properties/animation-timeline/scroll
repos/mdn/.../css/reference/properties/animation-timeline
- a named timeline (scroll-timeline-name on the scroller, referenced as a
<dashed-ident>) drives progress from any scroll container
repos/mdn/.../css/reference/properties/scroll-timeline-name
- module overview: repos/mdn/.../css/guides/scroll-driven_animations
The fill keyframe (scn-scroll-progress: scaleX(0)->scaleX(1)) + the default
animation-timeline live in app/styles/input.css keyed on data-slot, with a
prefers-reduced-motion fallback. The bar is decorative (aria-hidden) — it
mirrors scroll position the user already commands.
## Examples
<.scroll_progress /> # tracks the page
<.scroll_progress position="bottom" />
<.scroll_progress timeline="--article" /> # named scroller
"""
use Phoenix.Component
attr :position, :string, default: "top", values: ~w(top bottom)
attr :timeline, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
def scroll_progress(assigns) do
edge = if assigns.position == "bottom", do: "bottom-0", else: "top-0"
assigns = assign(assigns, :edge, edge)
~H"""
<div
data-slot="scroll-progress"
data-position={@position}
aria-hidden="true"
class={[
"pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15",
@edge,
@class
]}
{@rest}
>
<div
data-slot="scroll-progress-indicator"
style={if @timeline, do: "animation-timeline: #{@timeline}"}
class="h-full w-full origin-left bg-primary"
/>
</div>
"""
end
end
1. Save the file
Paste the markup; the scn-scroll-progress keyframe + animation-timeline live in input.css.
2. Use it
<div data-slot="scroll-progress" data-position="top" aria-hidden="true"
class="pointer-events-none fixed inset-x-0 top-0 z-50 h-1 w-full overflow-hidden bg-primary/15">
<div data-slot="scroll-progress-indicator" class="h-full w-full origin-left bg-primary"></div>
</div>View source
<!--
shadcn-htmx — raw HTML scroll-progress snippets.
A reading-position bar whose fill tracks scroll position — ZERO JavaScript,
pure CSS scroll-driven animation. The fill keyframe (scn-scroll-progress)
and the default animation-timeline: scroll(root block) live in input.css
keyed on data-slot="scroll-progress-indicator", with a prefers-reduced-motion
fallback. The bar is decorative (aria-hidden) — it mirrors the scroll
position the user already controls. Relies only on theme tokens.
Built on CSS scroll-driven animations:
animation-timeline: scroll() / scroll-timeline-name — see
repos/mdn/.../css/reference/properties/animation-timeline.
-->
<!-- Page reading bar (pinned to the top, tracks the page scroll) -->
<div data-slot="scroll-progress" data-position="top" aria-hidden="true"
class="pointer-events-none fixed inset-x-0 top-0 z-50 h-1 w-full overflow-hidden bg-primary/15">
<div data-slot="scroll-progress-indicator" class="h-full w-full origin-left bg-primary"></div>
</div>
<!-- Pinned to the bottom edge instead -->
<div data-slot="scroll-progress" data-position="bottom" aria-hidden="true"
class="pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1 w-full overflow-hidden bg-primary/15">
<div data-slot="scroll-progress-indicator" class="h-full w-full origin-left bg-primary"></div>
</div>
<!-- Driven by a named scroller: put `scroll-timeline-name: --article` on the
scroll container in your CSS, then point the fill at it. -->
<div data-slot="scroll-progress" data-position="top" aria-hidden="true"
class="pointer-events-none fixed inset-x-0 top-0 z-50 h-1 w-full overflow-hidden bg-primary/15">
<div data-slot="scroll-progress-indicator" style="animation-timeline: --article"
class="h-full w-full origin-left bg-primary"></div>
</div>
Examples
Page reading bar
Drop one near the top of your layout. It pins itself to the viewport and fills as the page scrolls — no value, no JS, no scroll handler.
The fill is a keyframe animation whose timeline is the page's own scrollbar (animation-timeline: scroll(root block)), so the browser advances it as you scroll. Because it duplicates a position the reader already controls, the bar is aria-hidden and never intercepts pointer events. Below is a self-contained scroller standing in for the page so you can see the fill move in the docs.
Scroll this box — the bar at its top edge tracks your position.
You've reached the end.
// In your layout, once, for the whole page:
<ScrollProgress />{{ scroll_progress() }}{{template "scroll-progress" (dict)}}<.scroll_progress /><div class="w-full">
<div data-test="demo-scroller" tabindex="0" role="region" aria-label="Scrollable article (progress demo)" class="relative h-56 w-full transform-gpu overflow-y-auto rounded-lg border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [scroll-timeline:--scroll-progress-demo_block]">
<div data-slot="scroll-progress" data-position="top" aria-hidden="true" class="pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15 top-0">
<div data-slot="scroll-progress-indicator" style="animation-timeline: --scroll-progress-demo" class="h-full w-full origin-left bg-primary">
</div>
</div>
<div class="space-y-4 p-4 pt-6">
<p class="text-sm text-muted-foreground">Scroll this box — the bar at its top edge tracks your position.</p>
<div class="h-40 rounded bg-muted">
</div>
<div class="h-40 rounded bg-muted">
</div>
<div class="h-40 rounded bg-muted">
</div>
<p class="text-sm text-muted-foreground">You've reached the end.</p>
</div>
</div>
</div>Named scroller
Track a specific scroll container instead of the page. Give the scroller a scroll-timeline-name and point the bar at it via timeline.
Set scroll-timeline-name: --article on the scroll container, then <ScrollProgress timeline="--article" />. The bar references that named timeline, so it fills with the panel's scroll rather than the window's — handy inside a modal or a split reading pane.
// CSS on the scroller: scroll-timeline: --article block;
<ScrollProgress timeline="--article" />{# scroll-timeline: --article block; on the scroller #}
{{ scroll_progress(timeline="--article") }}{{/* scroll-timeline: --article block; on the scroller */}}
{{template "scroll-progress" (dict "Timeline" "--article")}}<%!-- scroll-timeline: --article block; on the scroller --%>
<.scroll_progress timeline="--article" /><div class="w-full">
<div data-test="named-scroller" tabindex="0" role="region" aria-label="Scrollable article (named timeline demo)" class="relative h-56 w-full transform-gpu overflow-y-auto rounded-lg border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [scroll-timeline:--article-demo_block]">
<div data-slot="scroll-progress" data-position="top" aria-hidden="true" class="pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15 top-0">
<div data-slot="scroll-progress-indicator" style="animation-timeline: --article-demo" class="h-full w-full origin-left bg-primary">
</div>
</div>
<article class="space-y-3 p-4 pt-6 text-sm">
<h3 class="font-semibold">A short article</h3>
<div class="h-32 rounded bg-muted">
</div>
<div class="h-32 rounded bg-muted">
</div>
<div class="h-32 rounded bg-muted">
</div>
</article>
</div>
</div>Further reading
Bottom edge
Pin the bar to the foot of the viewport instead of the top with position="bottom".
Same scroll-driven fill, anchored to the bottom edge — useful when a sticky header already owns the top of the screen.
<ScrollProgress position="bottom" />{{ scroll_progress(position="bottom") }}{{template "scroll-progress" (dict "Position" "bottom")}}<.scroll_progress position="bottom" /><div class="w-full">
<div data-test="bottom-scroller" tabindex="0" role="region" aria-label="Scrollable article (bottom timeline demo)" class="relative h-56 w-full transform-gpu overflow-y-auto rounded-lg border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [scroll-timeline:--bottom-demo_block]">
<div data-slot="scroll-progress" data-position="bottom" aria-hidden="true" class="pointer-events-none fixed inset-x-0 z-50 h-1 w-full overflow-hidden bg-primary/15 bottom-0">
<div data-slot="scroll-progress-indicator" style="animation-timeline: --bottom-demo" class="h-full w-full origin-left bg-primary">
</div>
</div>
<div class="space-y-4 p-4">
<div class="h-40 rounded bg-muted">
</div>
<div class="h-40 rounded bg-muted">
</div>
<div class="h-40 rounded bg-muted">
</div>
</div>
</div>
</div>Further reading
API Reference
<ScrollProgress>
| Prop | Type | Default | Description |
|---|---|---|---|
position | "top"|"bottom" | "top" | Which viewport edge the bar pins to. |
timeline | string | — | A scroll-timeline-name (a --dashed-ident) to drive the bar from a named scroller instead of the whole page. Omit to track the page root via scroll(root block).MDNscroll-timeline-name |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |