Sticky Surface
A sticky surface component with scroll-based effects (border, shadow, surface swap, shrink, glass).
Scroll inside!
---import Button from '@bejamas/ui/components/Button.astro';import StickySurface from '@bejamas/ui/components/StickySurface.astro';---
<div class="overflow-y-auto max-h-[320px] w-full bg-border px-8 border-border"> <div class="h-[1000px] bg-muted"> <StickySurface effects={["line", "elevate"]} threshold="1rem" observeStuck class="bg-background p-4 flex justify-between items-center"> <p>Scroll inside!</p> <div class="flex items-center gap-2"> <Button size="sm" variant="outline" class="group-[.is-stuck]/surface:opacity-0 will-change-transform group-[.is-stuck]/surface:blur-sm transition-all duration-300 ease-out">Log in</Button> <Button size="sm" class="w-24 group-[.is-stuck]/surface:w-28 duration-500 ease-out"> <span class="group-[.is-stuck]/surface:blur-sm group-[.is-stuck]/surface:opacity-0 absolute group-[.is-stuck]/surface:hidden transition-discrete duration-500 ease-out">Contact</span> <span class="group-[.is-stuck]/surface:blur-none group-[.is-stuck]/surface:opacity-100 group-[.is-stuck]/surface:delay-150 opacity-0 transition-all blur-sm duration-500 ease-out">Get a demo</span> </Button> </div> </StickySurface> </div></div>Installation
Section titled “Installation” bunx bejamas add sticky-surface npx bejamas add sticky-surface pnpm dlx bejamas add sticky-surface yarn dlx bejamas add sticky-surface---/** * @component StickySurface * @title Sticky Surface * @description A sticky surface component with scroll-based effects (border, shadow, surface swap, shrink, glass). * * @preview * * <div class="overflow-y-auto max-h-[320px] w-full bg-border px-8 border-border"> * <div class="h-[1000px] bg-muted"> * <StickySurface effects={["line", "elevate"]} threshold="1rem" observeStuck class="bg-background p-4 flex justify-between items-center"> * <p>Scroll inside!</p> * <div class="flex items-center gap-2"> * <Button size="sm" variant="outline" class="group-[.is-stuck]/surface:opacity-0 will-change-transform group-[.is-stuck]/surface:blur-sm transition-all duration-300 ease-out">Log in</Button> * <Button size="sm" class="w-24 group-[.is-stuck]/surface:w-28 duration-500 ease-out"> * <span class="group-[.is-stuck]/surface:blur-sm group-[.is-stuck]/surface:opacity-0 absolute group-[.is-stuck]/surface:hidden transition-discrete duration-500 ease-out">Contact</span> * <span class="group-[.is-stuck]/surface:blur-none group-[.is-stuck]/surface:opacity-100 group-[.is-stuck]/surface:delay-150 opacity-0 transition-all blur-sm duration-500 ease-out">Get a demo</span> * </Button> * </div> * </StickySurface> * </div> * </div> */
import type { HTMLTag, Polymorphic } from "astro/types";import { cva } from "class-variance-authority";import { cn } from "@bejamas/ui/lib/utils";
type Props = { as: HTMLTag; threshold?: string | number; effects?: Array<"line" | "elevate" | "backdrop">; class?: string; observeStuck?: boolean; };
const { as = "div", threshold = "16px", effects = [], class: className = "", style = "", ...props} = Astro.props;
// Compose effect booleans for easier useconst hasLine = effects.includes("line");const hasElevate = effects.includes("elevate");const hasBackdrop = effects.includes("backdrop");
// CSS variable tokens (can be overridden via Tailwind arbitrary props)const defaultVars = { '--stuck-shadow': '0 2px 8px 0 rgb(0 0 0 / 0.06)', '--stuck-threshold': typeof threshold === "number" ? `${threshold}px` : threshold,};
const Tag = as;
// cva for sticky surface effectsconst stickySurfaceVariants = cva( [ "group/surface", "sticky", "top-0", "z-50", "w-full", "transition-all", "duration-300", "will-change-transform" ], { variants: { line: { true: "after:content-[''] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-black/15 after:opacity-0 supports-[animation-timeline:scroll()]:after:[animation:show-line_linear_both] supports-[animation-timeline:scroll()]:after:[animation-timeline:scroll(nearest_block)] supports-[animation-timeline:scroll()]:after:[animation-range:0_var(--stuck-threshold)]" }, elevate: { true: "shadow-none supports-[animation-timeline:scroll()]:[animation:stuck-shadow_linear_both] supports-[animation-timeline:scroll()]:[animation-timeline:scroll(nearest_block)] supports-[animation-timeline:scroll()]:[animation-range:0_var(--stuck-threshold)]" }, backdrop: { true: "backdrop-blur supports-[animation-timeline:scroll()]:[animation:stuck-backdrop_linear_both] supports-[animation-timeline:scroll()]:[animation-timeline:scroll(nearest_block)] supports-[animation-timeline:scroll()]:[animation-range:0_var(--stuck-threshold)]" } } });
const effectClasses = cn( stickySurfaceVariants({ line: hasLine, elevate: hasElevate, backdrop: hasBackdrop }), className);
const styleVars = Object.entries(defaultVars) ?.map(([k, v]) => `${k}:${v}`) ?.join(";");---
<Tag class={effectClasses} style={`${styleVars};${style}`} data-sticky-surface {...props}> <slot /></Tag>
<style>@supports (animation-timeline: scroll()) { /* Line fade-in */ @keyframes show-line { from { opacity: 0 } to { opacity: 1 } } /* Shadow fade-in */ @keyframes stuck-shadow { from { box-shadow: none } to { box-shadow: var(--stuck-shadow) } }}</style>
{Astro.props.observeStuck && ( <script is:inline> (() => { const surfaces = document.querySelectorAll('[data-sticky-surface]'); surfaces.forEach(surface => { // Insert a 1px sentinel right before the sticky element const sentinel = document.createElement('div'); sentinel.style.cssText = 'position:relative;height:0;opacity:0;visibility:hidden;'; surface.parentNode?.insertBefore(sentinel, surface);
const io = new IntersectionObserver(([e]) => { surface.classList.toggle('is-stuck', !e.isIntersecting); }, { threshold: [0, 1] });
io.observe(sentinel); }); })(); </script>)}| Prop | Type | Default |
|---|---|---|
as | HTMLTag | "div" |
threshold | string | number | "16px" |
effects | Array<"line" | "elevate" | "backdrop"> | [] |
class | string | "" |
observeStuck | boolean |