Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@subtleGradient
Created March 11, 2022 03:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save subtleGradient/340771c83563e3f89059482552bda0a1 to your computer and use it in GitHub Desktop.
Save subtleGradient/340771c83563e3f89059482552bda0a1 to your computer and use it in GitHub Desktop.
import useSize, { UseSizeOptions } from "@react-hook/size";
import {
Fragment,
ReactNode,
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef
} from "react";
import "./styles.css";
function useSizeRef(
options?: UseSizeOptions
): [RefObject<HTMLElement>, number, number] {
const measureRef = useRef<HTMLElement>(null);
const [width, height] = useSize(measureRef, options);
return [measureRef, width, height];
}
function useViewportEvent(
viewport: VisualViewport | Window | HTMLElement | undefined | false,
event: string,
handleEvent: () => unknown,
wantsEvents: unknown = true
) {
useEffect(() => {
if (!(wantsEvents && viewport)) return;
viewport.addEventListener(event, handleEvent, { passive: true });
return () => viewport.removeEventListener(event, handleEvent);
}, [viewport, event, handleEvent, wantsEvents]);
}
type ScrollDirection = undefined | "up" | "down";
function useScrollDirection(onChange?: (dir: ScrollDirection) => unknown) {
const lastScrollValue = useRef(-1);
const scrollDirectionRef = useRef<ScrollDirection>();
useViewportEvent(
window,
"scroll",
useCallback(() => {
const scrollTopPrevious = lastScrollValue.current;
const scrollTop = document.scrollingElement?.scrollTop ?? 0;
const nextDirection =
scrollTop > scrollTopPrevious
? "down"
: scrollTop < scrollTopPrevious
? "up"
: undefined;
lastScrollValue.current = scrollTop;
if (scrollDirectionRef.current !== nextDirection) {
scrollDirectionRef.current = nextDirection;
onChange?.(nextDirection);
}
}, [scrollDirectionRef, onChange])
);
return scrollDirectionRef;
}
export default function App() {
return (
<div
className="App"
style={{ display: "flex", alignItems: "stretch", margin: 55 }}
>
<h1>sidebar innards sticky top & bottom scrolling</h1>
<Body />
<StickySidebar data-testid="sidebarColumn" style={{ background: "#eee" }}>
<div style={{ border: "15px solid lime" }}>
<h2>
When scrolling up, the top of this sidebar is sticking to the top of
the viewport.
</h2>
<DebugContent size={10}>
<br />
asdkjfh askdjhfz sdkjhf kjhasd---
</DebugContent>
<h2>
When scrolling down, the bottom of this sidebar is sticking to the
bottom of the viewport.
</h2>
</div>
</StickySidebar>
</div>
);
}
const cssInnardsTop: any = "--sidebar-top";
const cssInnardsHeight: any = "--sidebar-height";
const cssOffscreenAnchor: any = "--sticky-offset";
const cssStickyTop: any = "--sticky-top";
const cssStickyBottom: any = "--sticky-bottom";
const cssWrapperTop: any = "--container-top";
const cssStickyMargin: any = "--sticky-span-margin-top";
// taking advantage of CSS var fallback values as a logic gate
// https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties#custom_property_fallback_values
// scrolling UP? unset this value so that the sidebar content will NOT stick to the top
// scrolling DOWN? set this value so that the sidebar content WILL stick to the top…
// using an offset that allows scrolling DOWN to the bottom of the sidebar content,
// further scrolling DOWN will be sticky to stop scrolling DOWN past the sidebar content bottom
// scrolling DOWN? unset this value so that the sidebar content will NOT stick to the bottom
// scrolling UP? set this value so that the sidebar content WILL stick to the bottom…
// using an offset that allows scrolling UP to the top of the sidebar content,
// further scrolling UP will be sticky to stop scrolling UP past the sidebar content top
type StickySidebarMode = "auto" | "top" | "scroll";
function StickySidebar({
mode = "auto",
children,
...props
}: { mode: StickySidebarMode } & JSX.IntrinsicElements["aside"]) {
const [stickyRef, , stickyResizedHeight] = useSizeRef();
const scrollDirectionRef = useRef<ScrollDirection>();
const update = useCallback(
(dir?: ScrollDirection) => {
if (dir === "up" || dir === "down") {
scrollDirectionRef.current = dir;
} else {
dir = scrollDirectionRef.current ?? "down";
}
const sticky = stickyRef.current as HTMLElement;
const container = sticky?.parentElement as HTMLElement;
if (!(sticky && container)) return;
updateStickyDOM(sticky, container, mode, dir);
},
[stickyRef, mode]
);
useScrollDirection(update);
useViewportEvent(window, "resize", update);
useLayoutEffect(update, [update, stickyResizedHeight]);
return (
<aside {...props}>
<div // sticky container
style={{
height: "100%",
[cssOffscreenAnchor]: `min(calc( 100vh - var(${cssInnardsHeight}) ), 0px)`,
minHeight: `var(${cssInnardsHeight})`
}}
>
<span style={{ display: "block", height: `var(${cssStickyMargin})` }} />
<section
ref={stickyRef}
style={{
position: "sticky",
top: `var(${cssStickyTop})`,
bottom: `var(${cssStickyBottom})`
}}
>
{children}
</section>
</div>
</aside>
);
}
function updateStickyDOM(
innards: HTMLElement,
wrapper: HTMLElement,
modeProp: StickySidebarMode,
dir: ScrollDirection
) {
const { style } = wrapper;
const innardsRect = innards.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
style.setProperty(cssInnardsTop, `${innardsRect.top}px`);
style.setProperty(cssWrapperTop, `${wrapperRect.top}px`);
style.setProperty(cssInnardsHeight, `${innardsRect.height}px`);
const isSidebarShorter = window.innerHeight > innardsRect.height;
const mode: StickySidebarMode =
modeProp === "auto" ? (isSidebarShorter ? "top" : "scroll") : modeProp;
// mode === "top"
let margin = "";
let top = "0px";
let bottom = "";
if (mode === "scroll") {
margin = `calc(var(${cssInnardsTop}) - var(${cssWrapperTop}))`;
top = dir === "down" ? `var(${cssOffscreenAnchor})` : "";
bottom = dir === "up" ? `var(${cssOffscreenAnchor})` : "";
}
style.setProperty(cssStickyMargin, margin);
style.setProperty(cssStickyTop, top);
style.setProperty(cssStickyBottom, bottom);
}
function DebugContent({
size = 10,
children
}: {
size?: number;
children: ReactNode;
}) {
return (
<>
{Array(size)
.fill(0)
.map((_, i) => (
<Fragment key={i}>
{children}
{i}
</Fragment>
))}
</>
);
}
function Body() {
return (
<div style={{ color: "#ccc" }}>
<DebugContent size={100}>
<p>
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Quasi
inventore beatae optio praesentium nisi accusantium tenetur ipsum
distinctio, rerum magnam fuga libero ipsam eius et officiis. Eos unde
officiis atque.
</p>
</DebugContent>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment