Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active October 4, 2024 19:32
Show Gist options
  • Save ryanflorence/76e165799a895651da935414010fa25d to your computer and use it in GitHub Desktop.
Save ryanflorence/76e165799a895651da935414010fa25d to your computer and use it in GitHub Desktop.
// TODO: make `pages` optional and measure the div when unspecified, this will
// allow more normal document flow and make it easier to do both mobile and
// desktop.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
////////////////////////////////////////////////////////////////////////////////
interface TStageProps {
frame: number;
length: number;
children: React.ReactNode;
DEBUG?: boolean;
}
interface TActorProps {
type?: "progress" | "frame";
start: number;
end?: number;
persistent?: boolean;
children: React.ReactNode;
}
interface TScrollStageProps {
pages: number;
fallbackFrame?: number;
fallbackLength?: number;
children: React.ReactNode;
DEBUG?: boolean;
}
interface TFrame {
isDefault?: boolean;
frame: number;
progress: number;
length: number;
}
////////////////////////////////////////////////////////////////////////////////
let StageContext = createContext<TFrame>({
isDefault: true,
frame: 0,
progress: 0,
length: 0,
});
let ActorContext = createContext<TFrame>({
isDefault: true,
frame: 0,
progress: 0,
length: 0,
});
////////////////////////////////////////////////////////////////////////////////
export let Stage = ({ frame, length, DEBUG, children }: TStageProps) => {
let progress = frame / length;
let context = useMemo(() => {
let context: TFrame = { frame, progress, length };
return context;
}, [frame, progress, length]);
if (DEBUG) console.log(context);
return <StageContext.Provider value={context} children={children} />;
};
export let Actor = ({
type = "progress",
start: startProp,
end: endProp,
persistent = false,
children,
}: TActorProps) => {
let stage = useContext(StageContext);
let actor = useActor();
let parent = actor.isDefault ? stage : actor;
let start = type === "progress" ? startProp * parent.length : startProp;
let end = endProp
? type === "progress"
? endProp * parent.length
: endProp
: parent.length;
let length = end - start;
let frame = parent.frame - start;
let progress = Math.max(0, Math.min(frame / length, 1));
let context = useMemo(() => {
let context: TFrame = { frame, progress, length };
return context;
}, [frame, progress, length]);
let onStage = persistent
? true
: parent.frame >= start && (endProp ? parent.frame < end : true);
return onStage ? (
<ActorContext.Provider value={context} children={children} />
) : null;
};
export let ScrollStage = ({
pages,
fallbackFrame = 0,
fallbackLength = 1080,
DEBUG = false,
children,
}: TScrollStageProps) => {
let ref = useRef<HTMLDivElement>(null);
let relativeScroll = useRelativeWindowScroll(ref, fallbackFrame);
// let getLength = () => document.documentElement.clientHeight * pages;
let getLength = () => window.innerHeight * pages;
let hydrated = useHydrated();
let [length, setLength] = useState<number>(() => {
return hydrated ? getLength() : fallbackLength;
});
// set length after server render
useEffect(() => setLength(getLength()), []);
useOnResize(useCallback(() => setLength(getLength()), [pages]));
return (
<Stage
frame={Math.max(0, Math.min(relativeScroll, length))}
length={length}
DEBUG={DEBUG}
>
<div ref={ref} style={{ height: `${pages * 100}vh` }}>
{children}
</div>
</Stage>
);
};
////////////////////////////////////////////////////////////////////////////////
export function useActor(): TFrame {
return useContext(ActorContext);
}
export function useStage(): TFrame {
return useContext(StageContext);
}
let hydrated = false;
function useHydrated() {
useEffect(() => {
hydrated = true;
});
return hydrated;
}
export function useOnResize(fn: () => void) {
useEffect(() => {
window.addEventListener("resize", fn);
return () => window.removeEventListener("resize", fn);
}, [fn]);
}
export function useWindowScroll(fallback: number = 0): number {
let [scroll, setScroll] = useState<number>(
typeof window === "undefined" ? fallback : window.scrollY
);
let handleScroll = useCallback(() => {
setScroll(window.scrollY);
}, []);
useEffect(() => {
handleScroll();
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useOnResize(handleScroll);
return scroll;
}
export function useRelativeWindowScroll(
ref: React.RefObject<HTMLElement>,
fallback: number = 0
): number {
let windowScroll = useWindowScroll(fallback);
if (!ref.current) return fallback;
return windowScroll - ref.current.offsetTop + window.innerHeight;
}
function App() {
return (
<ScrollStage pages={2}>
<Actor start={0} end={0.5}>
<p>I'll be here from start to 50%</p>
</Actor>
<Actor start={0.5}>
<p>I'll be here from 50% on.</p>
</Actor>
</ScrollStage>
)
}
// hooks access info about the progress
// for an actor (relative to stage) or the
// the entire stage
let actor = useActor()
let stage = useStage()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment