shshadcn-htmx

Components

Sticky Header

A header that pins to the top on scroll and visually reacts — gaining a shadow and a solid background — the moment it becomes stuck. Built on position: sticky plus a @container scroll-state(stuck: top) query, so there is no IntersectionObserver sentinel and zero JavaScript.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/sticky-header.json

2. Use it

components/ui/sticky-header.tsx
import { StickyHeader, StickyHeaderBar }
  from "@/components/ui/sticky-header"

// Needs a scroll-container ancestor (the page, or an overflow box).
<StickyHeader>
  <StickyHeaderBar class="flex h-14 items-center px-4">
    <span class="font-semibold">Inbox</span>
  </StickyHeaderBar>
</StickyHeader>
Or copy the source manually
components/ui/sticky-header.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Sticky Header — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A page / section / table header that pins on scroll AND visually reacts
// (shadow + solid background) the moment it becomes STUCK — with NO
// IntersectionObserver sentinel hack. The browser tells us it's stuck.
//
// How it works (all native, zero JS):
//   - The root is `position: sticky; top: <top>` so the platform pins it to
//     the top edge of its nearest scroll container ancestor.
//       repos/mdn/files/en-us/web/css/reference/properties/position/index.md
//       ("sticky": "scroll along with its container, until it is at the top
//        of the container … and will then stop scrolling, so it stays
//        visible.")
//   - The SAME element is a scroll-state query container
//     (`container-type: scroll-state`). A `@container scroll-state(stuck: top)`
//     query then matches whenever this sticky element is stuck to the top
//     edge, and applies styles to its DESCENDANTS.
//       repos/mdn/.../web/css/guides/conditional_rules/container_scroll-state_queries/index.md
//       ("stuck: Queries whether a container with a position value of sticky
//        is stuck to an edge of its scroll container ancestor. … you could
//        give them a different color scheme or layout.")
//       repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
//     This is exactly the MDN "Using `stuck` queries" recipe (a sticky
//     <header> that is BOTH the sticky element and the scroll-state
//     container), translated to our token system.
//
// The container query + the descendant reveal rules can't be expressed
// portably as Tailwind utilities (the styled target is a DESCENDANT of the
// query container, and `scroll-state(stuck: top)` isn't a first-class
// variant). So — exactly like Tree / Treegrid / Sidebar in this repo — the
// rules live in one tiny block scoped to [data-slot="sticky-header"] in
// app/styles/input.css. Children opt in to the stuck styling with
// data-sticky-revealed (shadow + solid background) so authors keep full
// control of which part of the header reacts.
//
// Progressive enhancement, not emulation: where scroll-state() is
// unsupported the header STILL pins (plain position: sticky); it just
// doesn't get the extra stuck shadow. We never polyfill the query.
//
// htmx-friendly: hx-* / data-* / aria-* forward via {...rest}, so a sticky
// table header or toolbar can re-fetch its body without losing its pin.

export type StickyHeaderElement = "div" | "header" | "section" | "nav"

// Element to render as. A page banner uses <header>; a sticky section title
// uses <header> inside its <section>; a sticky toolbar can use <div>.
const ELEMENT_BY_AS: Record<StickyHeaderElement, StickyHeaderElement> = {
  div: "div",
  header: "header",
  section: "section",
  nav: "nav",
}

// Root classes. The sticky pin + scroll-state container are set as inline
// utilities; the stuck reveal styling for descendants lives in the scoped
// CSS block (see header comment). We keep a base background so the header is
// never transparent over scrolling content even before it sticks.
const base =
  "sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 " +
  "[container-type:scroll-state]"

type StickyHeaderProps = PropsWithChildren<{
  as?: StickyHeaderElement
  class?: ClassValue
  // Offset from the top edge of the scroll container at which the header
  // pins (CSS `top`). Defaults to 0. Pass a Tailwind class via `class`
  // (e.g. "top-16") to pin below a fixed app bar instead.
  top?: number | string
  id?: string
  // Forward htmx attrs (e.g. a sticky table header that re-sorts its body),
  // plus data-* / aria-*.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
  role?: string
}>

export function StickyHeader(props: StickyHeaderProps) {
  const { children, as = "header", class: className, top, id, ...rest } = props
  const Tag = ELEMENT_BY_AS[as] as any
  // top defaults to 0 (pin flush to the scroll container's top edge). A
  // numeric value is treated as pixels; a string passes through verbatim.
  const topValue =
    top === undefined ? "0" : typeof top === "number" ? `${top}px` : top
  return (
    <Tag
      id={id}
      data-slot="sticky-header"
      class={cn(base, className)}
      style={`top:${topValue}`}
      {...rest}
    >
      {children}
    </Tag>
  )
}

// The reveal target. Wrap the part of the header that should react (gain a
// shadow + solid background) once the header is stuck. Multiple revealed
// regions are fine. The actual stuck styling is applied by the scoped CSS
// block via the data-sticky-revealed hook.
const revealedBase = "transition-shadow transition-colors duration-200"

type StickyHeaderBarProps = PropsWithChildren<{
  as?: "div" | "header" | "nav"
  class?: ClassValue
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function StickyHeaderBar(props: StickyHeaderBarProps) {
  const { children, as = "div", class: className, ...rest } = props
  const Tag = (as as string) as any
  return (
    <Tag
      data-slot="sticky-header-bar"
      data-sticky-revealed=""
      class={cn(revealedBase, className)}
      {...rest}
    >
      {children}
    </Tag>
  )
}

1. Save the file

Copy sticky-header.html into templates/components/.

2. Use it

templates/components/sticky-header.html
{% from "components/sticky-header.html" import
   sticky_header_open, sticky_header_close,
   sticky_header_bar_open, sticky_header_bar_close %}

{{ sticky_header_open() }}
  {{ sticky_header_bar_open(extra_class="flex h-14 items-center px-4") }}
    <span class="font-semibold">Inbox</span>
  {{ sticky_header_bar_close() }}
{{ sticky_header_close() }}
View source
templates/components/sticky-header.html
{# Sticky Header macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/sticky-header.tsx exactly.

   Pins on scroll via `position: sticky; top: <top>` and reacts when STUCK
   via `@container scroll-state(stuck: top)` — the root is BOTH the sticky
   element and the scroll-state container ([container-type:scroll-state]).
   The descendant reveal styling lives in a [data-slot="sticky-header"]
   block in app/styles/input.css; opt in with data-sticky-revealed.
     repos/mdn/files/en-us/web/css/reference/properties/position/index.md
     repos/mdn/.../web/css/guides/conditional_rules/container_scroll-state_queries/index.md
     repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md
   Zero JS — the browser drives the stuck state. #}

{% macro sticky_header_open(as="header", top="0", extra_class="", **attrs) %}
<{{ as }} data-slot="sticky-header"
  class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state] {{ extra_class }}"
  style="top:{{ top }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{% endmacro %}

{% macro sticky_header_close(as="header") %}</{{ as }}>{% endmacro %}

{% macro sticky_header_bar_open(as="div", extra_class="", **attrs) %}
<{{ as }} data-slot="sticky-header-bar" data-sticky-revealed=""
  class="transition-shadow transition-colors duration-200 {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{% endmacro %}

{% macro sticky_header_bar_close(as="div") %}</{{ as }}>{% endmacro %}

1. Save the file

Add sticky-header.tmpl alongside your templates.

2. Use it

components/sticky-header.tmpl
{{template "sticky_header" (dict "Body" (htmlSafe `
  {{template "sticky_header_bar" (dict
     "Class" "flex h-14 items-center px-4"
     "Body" (htmlSafe \`<span class="font-semibold">Inbox</span>\`))}}`))}}
View source
components/sticky-header.tmpl
{{/* Sticky Header templates — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/sticky-header.tsx exactly.

   Pins via `position: sticky; top: <Top>` and reacts when STUCK via
   `@container scroll-state(stuck: top)` — the root is BOTH the sticky
   element and the scroll-state container ([container-type:scroll-state]).
   Descendant reveal styling lives in the [data-slot="sticky-header"] block
   in app/styles/input.css; opt in with data-sticky-revealed. Zero JS.
     repos/mdn/files/en-us/web/css/reference/properties/position/index.md
     repos/mdn/.../web/css/guides/conditional_rules/container_scroll-state_queries/index.md
     repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md */}}

{{define "sticky_header"}}
{{- $as := or .As "header" -}}
{{- $top := or .Top "0" -}}
<{{$as}} data-slot="sticky-header"
  class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state] {{.Class}}"
  style="top:{{$top}}">{{.Body}}</{{$as}}>
{{end}}

{{define "sticky_header_bar"}}
{{- $as := or .As "div" -}}
<{{$as}} data-slot="sticky-header-bar" data-sticky-revealed=""
  class="transition-shadow transition-colors duration-200 {{.Class}}">{{.Body}}</{{$as}}>
{{end}}

1. Save the file

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

2. Use it

lib/my_app_web/components/sticky_header.ex
<.sticky_header>
  <.sticky_header_bar class="flex h-14 items-center px-4">
    <span class="font-semibold">Inbox</span>
  </.sticky_header_bar>
</.sticky_header>
View source
lib/my_app_web/components/sticky_header.ex
defmodule ShadcnHtmx.Components.StickyHeader do
  @moduledoc """
  Sticky Header — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/sticky-header.tsx so a Phoenix project renders the same
  markup our docs site renders.

  A page / section / table header that pins on scroll AND visually reacts
  (shadow + solid background) the moment it becomes STUCK — with no
  IntersectionObserver sentinel. The browser drives the stuck state; zero JS.

  How it works (all native):

    * the root is `position: sticky; top: <top>` so the platform pins it to
      the top edge of its scroll container ancestor
      (repos/mdn/files/en-us/web/css/reference/properties/position/index.md);
    * the SAME element is a scroll-state query container
      (`container-type: scroll-state`), and a
      `@container scroll-state(stuck: top)` rule applies the stuck styling to
      its descendants
      (repos/mdn/.../css/guides/conditional_rules/container_scroll-state_queries/index.md,
       repos/mdn/files/en-us/web/css/reference/at-rules/@container/index.md).

  The descendant reveal styling lives in a `[data-slot="sticky-header"]`
  block in app/styles/input.css; opt a region in with the
  `<.sticky_header_bar>` slot wrapper (data-sticky-revealed).

  ## Examples

      <.sticky_header>
        <.sticky_header_bar class="flex h-14 items-center px-4">
          <span class="font-semibold">Inbox</span>
        </.sticky_header_bar>
      </.sticky_header>

      # Pin below a fixed app bar, as a sticky section title
      <.sticky_header as="header" top="4rem">
        <.sticky_header_bar class="px-4 py-2 font-medium">Today</.sticky_header_bar>
      </.sticky_header>
  """

  use Phoenix.Component

  @base "sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 " <>
          "[container-type:scroll-state]"

  attr :as, :string, default: "header", values: ~w(div header section nav)
  attr :top, :string, default: "0"
  attr :class, :string, default: nil

  attr :rest, :global,
    include: ~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger id role)

  slot :inner_block, required: true

  def sticky_header(assigns) do
    assigns = assign(assigns, :base_class, @base)

    ~H"""
    <.dynamic_tag
      tag_name={@as}
      data-slot="sticky-header"
      class={[@base_class, @class]}
      style={"top:#{@top}"}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end

  attr :as, :string, default: "div", values: ~w(div header nav)
  attr :class, :string, default: nil
  attr :rest, :global

  slot :inner_block, required: true

  def sticky_header_bar(assigns) do
    ~H"""
    <.dynamic_tag
      tag_name={@as}
      data-slot="sticky-header-bar"
      data-sticky-revealed=""
      class={["transition-shadow transition-colors duration-200", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens. Needs a scroll-container ancestor.

2. Use it

snippets/sticky-header.html
<header data-slot="sticky-header"
  class="sticky z-30 bg-background/95 [container-type:scroll-state]" style="top:0">
  <div data-slot="sticky-header-bar" data-sticky-revealed=""
       class="flex h-14 items-center px-4 transition-shadow duration-200">
    <span class="font-semibold">Inbox</span>
  </div>
</header>
View source
snippets/sticky-header.html
<!--
  shadcn-htmx — raw Sticky Header snippet. Mirrors registry/ui/sticky-header.tsx.

  Pins on scroll via `position: sticky; top: 0` and reacts the moment it
  becomes STUCK via `@container scroll-state(stuck: top)`: the root is BOTH
  the sticky element AND the scroll-state container ([container-type:scroll-state]).
  The descendant reveal styling (shadow + solid background) lives in the
  [data-slot="sticky-header"] block in app/styles/input.css and is opted into
  with data-sticky-revealed. Zero JS — the browser drives the stuck state.

  Needs a SCROLL CONTAINER ancestor (here the .overflow-auto wrapper). Where
  scroll-state() is unsupported the header still pins; it just won't gain the
  extra stuck shadow (progressive enhancement).
    repos/mdn/files/en-us/web/css/reference/properties/position/index.md
    repos/mdn/.../web/css/guides/conditional_rules/container_scroll-state_queries/index.md
-->

<div class="relative h-72 overflow-auto rounded-lg border">
  <header data-slot="sticky-header"
          class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state]"
          style="top:0">
    <div data-slot="sticky-header-bar" data-sticky-revealed=""
         class="flex h-14 items-center justify-between px-4 transition-shadow transition-colors duration-200">
      <span class="font-semibold">Documents</span>
      <span class="text-sm text-muted-foreground">128 files</span>
    </div>
  </header>

  <div class="space-y-2 p-4 text-sm text-muted-foreground">
    <p>Scroll this panel — the header pins to the top and gains a shadow once stuck.</p>
    <p>Row 1</p><p>Row 2</p><p>Row 3</p><p>Row 4</p><p>Row 5</p>
    <p>Row 6</p><p>Row 7</p><p>Row 8</p><p>Row 9</p><p>Row 10</p>
    <p>Row 11</p><p>Row 12</p><p>Row 13</p><p>Row 14</p><p>Row 15</p>
  </div>
</div>

Examples

Basic — shadow on stuck

Scroll the panel: the header pins, and the bar gains a shadow + solid background the moment it sticks to the top.

The root is position: sticky AND a container-type: scroll-state container, so a @container scroll-state(stuck: top) rule can style its descendants only while it's stuck — no sentinel element, no observer. Where the query isn't supported the header still pins; it just skips the extra shadow.

Documents128 files

Row 1

Row 2

Row 3

Row 4

Row 5

Row 6

Row 7

Row 8

Row 9

Row 10

Row 11

Row 12

Row 13

Row 14

Row 15

Row 16

import { StickyHeader, StickyHeaderBar }
  from "@/components/ui/sticky-header"

// Needs a scroll-container ancestor (the page, or an overflow box).
<StickyHeader>
  <StickyHeaderBar class="flex h-14 items-center px-4">
    <span class="font-semibold">Inbox</span>
  </StickyHeaderBar>
</StickyHeader>
{% from "components/sticky-header.html" import
   sticky_header_open, sticky_header_close,
   sticky_header_bar_open, sticky_header_bar_close %}

{{ sticky_header_open() }}
  {{ sticky_header_bar_open(extra_class="flex h-14 items-center px-4") }}
    <span class="font-semibold">Inbox</span>
  {{ sticky_header_bar_close() }}
{{ sticky_header_close() }}
{{template "sticky_header" (dict "Body" (htmlSafe `
  {{template "sticky_header_bar" (dict
     "Class" "flex h-14 items-center px-4"
     "Body" (htmlSafe \`<span class="font-semibold">Inbox</span>\`))}}`))}}
<.sticky_header>
  <.sticky_header_bar class="flex h-14 items-center px-4">
    <span class="font-semibold">Inbox</span>
  </.sticky_header_bar>
</.sticky_header>
<div role="region" aria-label="Documents list — scrollable preview" tabindex="0" class="relative h-72 w-full overflow-auto rounded-lg border focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
  <header data-slot="sticky-header" class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state]" style="top:0">
    <div data-slot="sticky-header-bar" data-sticky-revealed="" class="transition-shadow transition-colors duration-200 flex h-14 items-center justify-between px-4">
      <span class="font-semibold">Documents</span>
      <span class="text-sm text-muted-foreground">128 files</span>
    </div>
  </header>
  <div class="space-y-2 p-4 text-sm text-muted-foreground">
    <p>Row 1</p>
    <p>Row 2</p>
    <p>Row 3</p>
    <p>Row 4</p>
    <p>Row 5</p>
    <p>Row 6</p>
    <p>Row 7</p>
    <p>Row 8</p>
    <p>Row 9</p>
    <p>Row 10</p>
    <p>Row 11</p>
    <p>Row 12</p>
    <p>Row 13</p>
    <p>Row 14</p>
    <p>Row 15</p>
    <p>Row 16</p>
  </div>
</div>

Section headers — multiple sticky titles

Each section title pins in turn. Whichever is stuck shows the shadow; the others sit flush above their content.

Because each header is its own scroll-state container, the query is evaluated per element — exactly the MDN “sticky reader” recipe. No coordination code is needed between sections.

Yesterday

Row 1

Row 2

Row 3

Row 4

Row 5

Row 6

Last week

Row 7

Row 8

Row 9

Row 10

Row 11

Row 12

Row 13

Row 14

<section>
  <StickyHeader>
    <StickyHeaderBar class="bg-muted/60 px-4 py-2 text-sm font-medium">
      Yesterday
    </StickyHeaderBar>
  </StickyHeader>
  {/* section rows… */}
</section>
{{ sticky_header_open() }}
  {{ sticky_header_bar_open(extra_class="bg-muted/60 px-4 py-2 text-sm font-medium") }}
    Yesterday
  {{ sticky_header_bar_close() }}
{{ sticky_header_close() }}
{{template "sticky_header" (dict "Body" (htmlSafe `
  {{template "sticky_header_bar" (dict
     "Class" "bg-muted/60 px-4 py-2 text-sm font-medium"
     "Body" "Yesterday")}}`))}}
<.sticky_header>
  <.sticky_header_bar class="bg-muted/60 px-4 py-2 text-sm font-medium">
    Yesterday
  </.sticky_header_bar>
</.sticky_header>
<div role="region" aria-label="Grouped sections — scrollable preview" tabindex="0" class="relative h-72 w-full overflow-auto rounded-lg border focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
  <section>
    <header data-slot="sticky-header" class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state]" style="top:0">
      <div data-slot="sticky-header-bar" data-sticky-revealed="" class="transition-shadow transition-colors duration-200 bg-muted/60 px-4 py-2 text-sm font-medium">Yesterday</div>
    </header>
    <div class="space-y-2 p-4 text-sm text-muted-foreground">
      <p>Row 1</p>
      <p>Row 2</p>
      <p>Row 3</p>
      <p>Row 4</p>
      <p>Row 5</p>
      <p>Row 6</p>
    </div>
  </section>
  <section>
    <header data-slot="sticky-header" class="sticky z-30 bg-background/95 supports-[backdrop-filter]:bg-background/80 [container-type:scroll-state]" style="top:0">
      <div data-slot="sticky-header-bar" data-sticky-revealed="" class="transition-shadow transition-colors duration-200 bg-muted/60 px-4 py-2 text-sm font-medium">Last week</div>
    </header>
    <div class="space-y-2 p-4 text-sm text-muted-foreground">
      <p>Row 7</p>
      <p>Row 8</p>
      <p>Row 9</p>
      <p>Row 10</p>
      <p>Row 11</p>
      <p>Row 12</p>
      <p>Row 13</p>
      <p>Row 14</p>
    </div>
  </section>
</div>

API Reference

Sticky Header

PropTypeDefaultDescription
as"header"|"div"|"section"|"nav""header"
Semantic element to render as. A page banner or section title uses header; a sticky toolbar can use div.MDN<header> element
topnumber|string0
Offset from the scroll container's top edge at which the header pins (CSS top). A number is treated as pixels; a string passes through verbatim. The stuck query keys off this same edge.MDNposition: sticky
idstring
Forwarded to the root element.
StickyHeaderBar.as"div"|"header"|"nav""div"
Element for a reveal region (the part that gains a shadow + solid background when stuck). Carries data-sticky-revealed.
StickyHeaderBar.classstring
Layout/spacing classes for the reveal region (e.g. flex h-14 items-center px-4). The stuck shadow + background are applied by the scoped [data-slot="sticky-header"] CSS block via the data-sticky-revealed hook.MDN@container scroll-state(stuck)
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference