← ui

scrims

ui

fog at the window's top and bottom as the page scrolls, and at a row's sides

npx shadcn add @ag/scrims
Already installed? Preview an update
npx shadcn add @ag/scrims --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • ag-edge-fade-y keeps a covered foot whole too (--edge-cover-end, a length), as it did the top (--edge-cover): a foot laid over a scroller.
Updated 2026-10-14 · release notes

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

scrims.demo.tsx
"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

scrims.tsx
"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"
        />
      )}
    </>
  )
}
scrims.css
@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;
  }
}
edge-fade.ts
// 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 },
  )