/* Copyright 2026 Marimo. All rights reserved. */ import { ChevronDownIcon } from "lucide-react"; import { type ReactNode, type RefObject, useEffect, useRef, useState, } from "react"; import { cn } from "@/utils/cn"; /** Pixels of scroll before the hint is considered dismissed. */ const TOP_THRESHOLD_PX = 8; /** Ignore sub-pixel overflow from rounding / borders. */ const OVERFLOW_TOLERANCE_PX = 1; /** * Whether a scroll container should show a "more content below" affordance. * Visible only when content overflows and the viewport is still near the top. */ export function shouldShowScrollHint(options: { scrollHeight: number; clientHeight: number; scrollTop: number; }): boolean { const { scrollHeight, clientHeight, scrollTop } = options; const overflowing = scrollHeight > clientHeight + OVERFLOW_TOLERANCE_PX; const atTop = scrollTop <= TOP_THRESHOLD_PX; return overflowing && atTop; } /** * Measure only up to the first unrevealed fragment so * the hint reflects currently revealed content, not future fragment steps. */ function measureRevealedHeight(el: HTMLElement): number { const firstUnrevealed = el.querySelector( ".fragment:not(.visible)", ); if (!firstUnrevealed) { return el.scrollHeight; } const fragmentTop = firstUnrevealed.getBoundingClientRect().top; return fragmentTop - el.getBoundingClientRect().top + el.scrollTop; } /** * Tracks whether a scroll container is overflowing at the top, so we can * surface a scroll affordance. */ function useScrollHint( scrollRef: RefObject, contentRef: RefObject, ): boolean { const [showHint, setShowHint] = useState(false); useEffect(() => { const el = scrollRef.current; const content = contentRef.current; if (!el || !content) { return; } let frame = 0; const update = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { setShowHint( shouldShowScrollHint({ scrollHeight: measureRevealedHeight(el), clientHeight: el.clientHeight, scrollTop: el.scrollTop, }), ); }); }; update(); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(el); resizeObserver.observe(content); // Fragments reveal via class changes (opacity/visibility, no layout shift), // which the ResizeObserver can't see, so re-check on class mutations. Only // attach on slides that have fragments to avoid reacting to unrelated class // churn (e.g. animating widgets) on the common non-fragment slide. const fragmentObserver = content.querySelector(".fragment") ? new MutationObserver(update) : null; fragmentObserver?.observe(content, { subtree: true, attributes: true, attributeFilter: ["class"], }); el.addEventListener("scroll", update, { passive: true }); return () => { cancelAnimationFrame(frame); resizeObserver.disconnect(); fragmentObserver?.disconnect(); el.removeEventListener("scroll", update); }; }, [scrollRef, contentRef]); return showHint; } const ScrollHintOverlay = ({ className }: { className?: string }) => { return (
Scroll for more
); }; /** * Full-height scroll container for a slide. Shows a dismissible "Scroll for * more" cue when content overflows the frame (easy to miss in fullscreen when * OS overlay scrollbars are hidden). */ export const SlideScrollContainer = ({ children, className, }: { children: ReactNode; className?: string; }) => { const scrollRef = useRef(null); const contentRef = useRef(null); const showHint = useScrollHint(scrollRef, contentRef); return (
{children}
{showHint && }
); };