shshadcn-htmx

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

2. Use it

components/ui/scroll-progress.tsx
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 scroller
Or copy the source manually
components/ui/scroll-progress.tsx
/** @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

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

components/scroll-progress.tmpl
{{template "scroll-progress" (dict)}}
{{template "scroll-progress" (dict "Position" "bottom")}}
{{template "scroll-progress" (dict "Timeline" "--article")}}
View source
components/scroll-progress.tmpl
{{/* 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

lib/my_app_web/components/scroll_progress.ex
<.scroll_progress />
<.scroll_progress position="bottom" />
<.scroll_progress timeline="--article" />
View source
lib/my_app_web/components/scroll_progress.ex
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

snippets/scroll-progress.html
<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
snippets/scroll-progress.html
<!--
  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&#39;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.

A short article

// 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>

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>

PropTypeDefaultDescription
position"top"|"bottom""top"
Which viewport edge the bar pins to.
timelinestring
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
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference