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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/* 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
<.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
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
<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
<!--
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.
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.
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
<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>Further reading
API Reference
Sticky Header
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
top | number|string | 0 | 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 |
id | string | — | 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.class | string | — | 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) |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |