fog at the window's top and bottom as the page scrolls, and at a row's sides
npx shadcn add @ag/scrimsAlready installed? Preview an update
npx shadcn add @ag/scrims --dry-runYour installed source stays unchanged until you update it. How updates work
ag-edge-fade-ykeeps a covered foot whole too (--edge-cover-end, a length), as it did the top (--edge-cover): a foot laid over a scroller.
also called edge fade
shelf glass & surfaces
used by markdown, preview-picker, dialog, tree, table, code, scroll-area, glass-display, chip-input, people-picker, data-view, select, log, sheet, icon-picker, player, trace, telegram-chat
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Image } from "@/registry/base-nova/ui/image"
import { Scrims } from "@/registry/base-nova/ui/scrims"
import {
NO_SCROLLBAR,
ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// A fog is the colour of the page, so it can only be SEEN over something that is not:
// photographs run to the viewport edges here, and the blur under the fog is the tell.
// Over flat panels the whole component is invisible and looks broken.
const SHOTS = [1015, 1016, 1018, 1021, 1024, 1039] as const
const shot = (id: number) => `https://picsum.photos/id/${id}/1200/700`
const NOTES = [
"Scroll. The top fog arrives over the first 160px and the bottom one leaves over the last 140px, so the page opens and closes clean.",
"Neither is ever transitioned. A scrim that crossfades over 500ms is still crossfading while a smooth anchor scroll is flying, which is what read as a weird effect.",
"Linked to the scroll instead, it is simply where the scroll is: drag the scrollbar and the fog tracks your hand exactly.",
"A page with a sticky blurred header already HAS a top scrim. Turn this one off there, with top={false}.",
"On a page too short to scroll the timeline never runs, so both rest at zero and nothing fogs a page that cannot move.",
"It is a masked backdrop-blur over a gradient: no layout, no pointer events, and nothing for the content underneath to know about.",
] as const
// #region inset
/** A scrolling inset of its own: the overlays stand in its positioned box and follow
* its scroll, not the page's. */
function Inset() {
const scroller = React.useRef<HTMLDivElement>(null)
return (
<div className="relative h-48 overflow-hidden rounded-2xl">
{/* edge: this is what the scrims are shown on; they are its fade. The
browser's bar hidden (it took a gutter that cut the pictures), the
thumbs laid over beside it. */}
<div
ref={scroller}
className={`h-full space-y-2 overflow-y-auto ${NO_SCROLLBAR}`}
>
{SHOTS.map((id) => (
<Image
key={id}
src={shot(id)}
alt=""
width={1200}
height={700}
className="block w-full object-cover"
/>
))}
</div>
<ScrollThumbs scroller={scroller} />
<Scrims position="absolute" scrollRef={scroller} />
</div>
)
}
// #endregion
export default function Demo() {
const [top, setTop] = React.useState(true)
const [bottom, setBottom] = React.useState(true)
return (
<div className="space-y-8">
<Sample
name="inset"
glance
title="fog in a scrolling inset"
description="the same fogs over a scroller of its own: position absolute in its box, scrollRef on what scrolls"
with="inset"
>
<Inset />
</Sample>
<Sample
name="page"
title="fog at the page's edges"
description="toggle either edge and scroll: the fog follows the scroll, never a transition"
presentation
>
<div className="space-y-16">
<Scrims top={top} bottom={bottom} />
{/* Above the scrims (z-20) so the controls stay readable through the fog. */}
<div className="sticky top-4 z-30 w-fit rounded-lg bg-background/80 backdrop-blur">
<ToggleGroup
multiple
aria-label="scrims"
value={[...(top ? ["top"] : []), ...(bottom ? ["bottom"] : [])]}
onValueChange={(on) => {
setTop(on.includes("top"))
setBottom(on.includes("bottom"))
}}
>
<ToggleGroupItem value="top">top</ToggleGroupItem>
<ToggleGroupItem value="bottom">bottom</ToggleGroupItem>
</ToggleGroup>
</div>
{SHOTS.map((id, i) => (
<section key={id} className="space-y-4">
<div className="font-mono text-xs text-muted-foreground">
0{i + 1}
</div>
<Image
src={shot(id)}
alt=""
width={1200}
height={700}
className="w-full rounded-2xl border border-border object-cover"
/>
<p className="max-w-prose text-[0.9375rem] leading-relaxed text-foreground/80">
{NOTES[i]}
</p>
</section>
))}
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// Top and bottom viewport scrims, scroll-linked (see scrims.css for why they are never
// transitioned). Pure CSS where animation-timeline: scroll() exists; a passive scroll
// listener measures opacity elsewhere. Place it inside the element that scrolls
// (the document, or a scrolling inset): the timeline is scroll(nearest).
import * as React from "react"
import { cn } from "@/lib/utils"
import "./scrims.css"
export interface ScrimsProps {
/** A sticky blurred header IS a top scrim; such a page opts out of this one. */
top?: boolean
bottom?: boolean
/** Absolute overlays stay inside a positioned container, such as a phone. */
position?: "fixed" | "absolute"
/** Static scrims protect floating chrome even at the end of a conversation. */
mode?: "scroll" | "static"
className?: string
/** Explicit scroll owner, independent of where the overlays are painted. */
scrollRef?: React.RefObject<HTMLElement | null>
}
export function Scrims({
top = true,
bottom = true,
position = "fixed",
mode = "scroll",
className,
scrollRef,
}: ScrimsProps) {
const topRef = React.useRef<HTMLDivElement>(null)
const bottomRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (mode === "static" || (!top && !bottom)) return
if (!scrollRef && CSS.supports("animation-timeline: scroll()")) return
let ancestor = (topRef.current ?? bottomRef.current)?.parentElement
while (
ancestor &&
!/(auto|scroll|hidden)/.test(getComputedStyle(ancestor).overflowY)
)
ancestor = ancestor.parentElement
const target =
scrollRef?.current ??
ancestor ??
(document.scrollingElement as HTMLElement)
const measure = () => {
const y = target.scrollTop
const remaining = target.scrollHeight - target.clientHeight - y
if (topRef.current)
topRef.current.style.opacity = String(Math.max(0, Math.min(1, y / 160)))
if (bottomRef.current)
bottomRef.current.style.opacity = String(
Math.max(0, Math.min(1, remaining / 140)),
)
}
const source = target === document.scrollingElement ? window : target
source.addEventListener("scroll", measure, { passive: true })
window.addEventListener("resize", measure)
const resize = new ResizeObserver(measure)
const observe = () => {
resize.disconnect()
resize.observe(target)
for (const child of target.children) resize.observe(child)
measure()
}
const mutation = new MutationObserver(observe)
mutation.observe(target, {
childList: true,
subtree: true,
characterData: true,
})
observe()
measure()
return () => {
source.removeEventListener("scroll", measure)
window.removeEventListener("resize", measure)
resize.disconnect()
mutation.disconnect()
}
}, [mode, top, bottom, scrollRef])
return (
<>
{bottom && (
<div
ref={bottomRef}
data-slot="scrim-bottom"
style={{ margin: 0, animation: scrollRef ? "none" : undefined }}
aria-hidden
data-position={position}
data-mode={mode}
className={cn("ag-scrim", className)}
data-edge="bottom"
/>
)}
{top && (
<div
ref={topRef}
data-slot="scrim-top"
style={{ margin: 0, animation: scrollRef ? "none" : undefined }}
aria-hidden
data-position={position}
data-mode={mode}
className={cn("ag-scrim", className)}
data-edge="top"
/>
)}
</>
)
}@layer theme, base, components, utilities;
@layer components {
/* scrims: viewport fogs that keep the section below from competing with the one on
screen. Masked backdrop-blur + gradient, no layout, no pointer. Their opacity is
scroll-LINKED, never transitioned: the top one fades in over the first 160px of
scroll, the bottom one fades out over the last 140px, and on a page that cannot
scroll the timeline is inactive so both rest at 0. A transitioned scrim (opacity
0→1 over 500ms) crossfades WHILE a smooth anchor scroll is flying; that was the
"weird effects" complaint. Linked to scroll it simply is where the scroll is. */
.ag-scrim {
position: fixed;
right: 0;
left: 0;
z-index: 20;
/* A margin on a fixed box is part of where it lands: `top + margin + height +
margin + bottom` has to equal the viewport, so a parent's `space-y-16` (which in
Tailwind v4 is a margin-block-end on every child but the last) pushed the bottom
fog 64px UP off the edge, with the page showing underneath it. The component
resets margin inline so parent spacing utilities cannot override it across
cascade layers. */
pointer-events: none;
opacity: 0;
-webkit-backdrop-filter: blur(var(--scrim-blur, 4px));
backdrop-filter: blur(var(--scrim-blur, 4px));
}
.ag-scrim[data-edge="top"] {
top: 0;
height: var(--scrim-top-height, 5rem);
background: linear-gradient(
to bottom,
color-mix(
in oklab,
var(--scrim-color, var(--background)) var(--scrim-strength, 90%),
transparent
)
var(--scrim-start-stop, 0%),
color-mix(
in oklab,
var(--scrim-color, var(--background)) var(--scrim-mid-strength, 35%),
transparent
)
var(--scrim-mid-stop, 50%),
transparent
);
mask-image: linear-gradient(
to bottom,
black var(--scrim-solid-stop, 25%),
transparent
);
}
.ag-scrim[data-edge="bottom"] {
bottom: 0;
height: var(--scrim-bottom-height, 7rem);
background: linear-gradient(
to top,
color-mix(
in oklab,
var(--scrim-color, var(--background)) var(--scrim-strength, 90%),
transparent
),
color-mix(
in oklab,
var(--scrim-color, var(--background)) var(--scrim-mid-strength, 35%),
transparent
)
var(--scrim-mid-stop, 50%),
transparent
);
mask-image: linear-gradient(
to top,
black var(--scrim-solid-stop, 25%),
transparent
);
}
.ag-scrim[data-position="absolute"] {
position: absolute;
}
.ag-scrim[data-mode="static"] {
opacity: 1;
}
@keyframes ag-scrim-top {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ag-scrim-bottom {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@supports (animation-timeline: scroll()) {
.ag-scrim[data-mode="scroll"][data-edge="top"] {
animation: ag-scrim-top linear both;
animation-timeline: scroll(nearest);
animation-range: 0 160px;
}
.ag-scrim[data-mode="scroll"][data-edge="bottom"] {
animation: ag-scrim-bottom linear both;
animation-timeline: scroll(nearest);
animation-range: calc(100% - 140px) 100%;
}
}
/* The same idea for anything that scrolls sideways (a command, a row of
icons): `ag-edge-fade` on the scroller hides its scrollbar and fades the side
that has more to show, read from the scroll itself: at the start the end
fades, at the end the start, between them both (--edge-fade, 1.5rem). Nothing
scrolls, the timeline is inactive and nothing fades.
Each edge's fade grows over the first `--edge-fade` of scroll past it, in
pixels, never a share of the scroll: as a share (10% of it) the fade of a
5,000-line log reached its full length only thousands of pixels in, and was a
hard cut a few hundred from its end. An edge with less hidden than a fade's
length fades only as much as is hidden. Every fade below is two animations, one
per edge, each ranged on its own end of the scroll; `animation-range` follows
`animation`, whose shorthand resets it. */
.ag-edge-fade {
overflow-x: auto;
scrollbar-width: none;
}
.ag-edge-fade::-webkit-scrollbar {
display: none;
}
@supports (animation-timeline: scroll()) {
.ag-edge-fade {
mask-image: linear-gradient(
to right,
transparent,
#000 var(--ag-edge-fade-start),
#000 calc(100% - var(--ag-edge-fade-end)),
transparent
);
animation:
ag-edge-fade-start linear both,
ag-edge-fade-end linear both;
animation-timeline: scroll(self inline), scroll(self inline);
animation-range:
0 var(--edge-fade, 1.5rem),
calc(100% - var(--edge-fade, 1.5rem)) 100%;
}
}
/* `ag-edge-fade-y`: the same for anything that scrolls up and down (a
kanban column, a capped list): its top and foot fade to nothing as the
scroll says there is more, with no colour of its own, so it sits on any
surface (glass included) without a band. A scroller whose top is covered by
something pinned inside it (a box's head, a table's header row) says how much
(`--edge-cover`, a length): that much stays whole and the top's fade starts
below it, where the rows come out from under the cover. A foot laid over its
bottom (a panel's foot) is `--edge-cover-end`, the same at the other end. */
.ag-edge-fade-y {
overflow-y: auto;
scrollbar-width: none;
}
.ag-edge-fade-y::-webkit-scrollbar {
display: none;
}
@supports (animation-timeline: scroll()) {
.ag-edge-fade-y {
mask-image: linear-gradient(
to bottom,
#000 var(--edge-cover, 0px),
transparent var(--edge-cover, 0px),
#000 calc(var(--edge-cover, 0px) + var(--ag-edge-fade-start)),
#000 calc(100% - var(--edge-cover-end, 0px) - var(--ag-edge-fade-end)),
transparent calc(100% - var(--edge-cover-end, 0px)),
#000 calc(100% - var(--edge-cover-end, 0px))
);
animation:
ag-edge-fade-start linear both,
ag-edge-fade-end linear both;
animation-timeline: scroll(self block), scroll(self block);
animation-range:
0 var(--edge-fade, 1.5rem),
calc(100% - var(--edge-fade, 1.5rem)) 100%;
}
}
/* `ag-edge-fade-xy`: both, for what scrolls both ways (a table, a tree, a wide
listing): the two fades multiplied (`intersect`), each read from its own axis
of the scroll. A covered top (`--edge-cover`, a pinned header row) and a
covered start (`--edge-cover-x`, a pinned first column) stay whole. */
.ag-edge-fade-xy {
overflow: auto;
scrollbar-width: none;
}
.ag-edge-fade-xy::-webkit-scrollbar {
display: none;
}
@supports (animation-timeline: scroll()) {
.ag-edge-fade-xy {
mask-image:
linear-gradient(
to bottom,
#000 var(--edge-cover, 0px),
transparent var(--edge-cover, 0px),
#000 calc(var(--edge-cover, 0px) + var(--ag-edge-fade-start)),
#000 calc(100% - var(--ag-edge-fade-end)),
transparent
),
linear-gradient(
to right,
#000 var(--edge-cover-x, 0px),
transparent var(--edge-cover-x, 0px),
#000 calc(var(--edge-cover-x, 0px) + var(--ag-edge-fade-x-start)),
#000 calc(100% - var(--ag-edge-fade-x-end)),
transparent
);
mask-composite: intersect;
animation:
ag-edge-fade-start linear both,
ag-edge-fade-end linear both,
ag-edge-fade-x-start linear both,
ag-edge-fade-x-end linear both;
animation-timeline:
scroll(self block), scroll(self block), scroll(self inline),
scroll(self inline);
animation-range:
0 var(--edge-fade, 1.5rem),
calc(100% - var(--edge-fade, 1.5rem)) 100%,
0 var(--edge-fade, 1.5rem),
calc(100% - var(--edge-fade, 1.5rem)) 100%;
}
}
}
/* The fades are lengths the scroll moves: registered, so they interpolate, and 0
wherever there is no scroll. */
@property --ag-edge-fade-x-start {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --ag-edge-fade-x-end {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes ag-edge-fade-x-start {
from {
--ag-edge-fade-x-start: 0px;
}
to {
--ag-edge-fade-x-start: var(--edge-fade, 1.5rem);
}
}
@keyframes ag-edge-fade-x-end {
from {
--ag-edge-fade-x-end: var(--edge-fade, 1.5rem);
}
to {
--ag-edge-fade-x-end: 0px;
}
}
@property --ag-edge-fade-start {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --ag-edge-fade-end {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes ag-edge-fade-start {
from {
--ag-edge-fade-start: 0px;
}
to {
--ag-edge-fade-start: var(--edge-fade, 1.5rem);
}
}
@keyframes ag-edge-fade-end {
from {
--ag-edge-fade-end: var(--edge-fade, 1.5rem);
}
to {
--ag-edge-fade-end: 0px;
}
}// The edge fades (scrims.css: `ag-edge-fade`, `-y`, `-xy`), and the one repair they
// need. A fade is read from its box's scroll through scroll-driven animations, and a
// box with nothing to scroll has no timeline, so the spec gives it no fade. Chromium
// (151 to 154 at least) holds each fade at its last value instead, the moment the
// timeline goes away: content that shrinks below the box while it is scrolled (a
// filter, a tab switch) pulls the scroll back to its start and leaves the top's fade
// drawn over a list with nothing above it. That pull is a scroll event, so one
// listener on the document hears it for every faded box, and a fade whose axis no
// longer overflows is restarted, which reads its timeline afresh. WebKit settles a
// fraction of a pixel off, nothing to see. An item that fades imports this, never
// the stylesheet alone (`scripts/check-scroll-edges.ts`).
import "./scrims.css"
/** Restart the fades on `box` whose axis has nothing to scroll: their timeline is
* gone, so restarted they draw nothing. */
function settle(box: HTMLElement) {
const still = {
y: box.scrollHeight > box.clientHeight,
x: box.scrollWidth > box.clientWidth,
}
for (const animation of box.getAnimations()) {
if (!(animation instanceof CSSAnimation)) continue
const name = animation.animationName
if (!name.startsWith("ag-edge-fade")) continue
if (still[name.startsWith("ag-edge-fade-x") ? "x" : "y"]) continue
animation.cancel()
animation.play()
}
}
if (typeof document !== "undefined")
document.addEventListener(
"scroll",
(event) => {
const box = event.target
if (!(box instanceof HTMLElement)) return
// A box that stops overflowing lands at its start; anywhere else it still
// scrolls and its timeline is live.
if (box.scrollTop === 0 && box.scrollLeft === 0) settle(box)
},
{ capture: true, passive: true },
)