Instantly share code, notes, and snippets.
Created
March 11, 2022 03:12
-
Save subtleGradient/340771c83563e3f89059482552bda0a1 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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