From ba2ad1ff151bed4b46e1b1632e9bb33ee4d7238a Mon Sep 17 00:00:00 2001 From: Zach White Date: Fri, 4 Oct 2024 13:48:26 -0700 Subject: [PATCH] Adding stacking image cards effect to main page --- README.md | 4 +- src/app/components/ImageCards.module.scss | 13 +- src/app/components/ImageCards.tsx | 56 +++++-- src/app/lib/aat.ts | 192 ++++++++++++++++++++++ src/app/lib/helpers.ts | 21 +++ src/app/lib/utils.ts | 0 src/app/navbar.tsx | 9 +- src/app/page.tsx | 61 +++++-- 8 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 src/app/lib/aat.ts create mode 100644 src/app/lib/helpers.ts create mode 100644 src/app/lib/utils.ts diff --git a/README.md b/README.md index 498d36d..04ea909 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The documentation is available at [once-ui.com/docs](https://once-ui.com/docs). Connect with us on X or LinkedIn. -Lorant Toth: [X](https://x.com/lorant_one), [LinkedIn](https://www.linkedin.com/in/lorant-one/) +Lorant Toth: [X](https://x.com/lorant_one), [LinkedIn](https://www.linkedin.com/in/lorant-one/) Zsofia Komaromi: [X](https://x.com/zsofiakomaromi), [LinkedIn](https://www.linkedin.com/in/zsofiakomaromi/)

@@ -39,7 +39,7 @@ Distributed under the MIT License. See `LICENSE.txt` for more information. # **Once UI for Figma** -Once UI is also available for Figma. +Once UI is also available for Figma. Design and prototype entire products from scratch in hours. Use the same tokens and components as the Next.js design system. Grab a copy from the [Figma Community](https://figma.com/). diff --git a/src/app/components/ImageCards.module.scss b/src/app/components/ImageCards.module.scss index 11feb7f..6993e23 100644 --- a/src/app/components/ImageCards.module.scss +++ b/src/app/components/ImageCards.module.scss @@ -48,13 +48,14 @@ } } - .space { - height: 90vh; - } +} - .space--small { - height: 40vh; - } +.space { + height: 50vh; +} + +.spacesmall { + height: 20vh; } @media (max-width: 600px) { diff --git a/src/app/components/ImageCards.tsx b/src/app/components/ImageCards.tsx index 1c9788a..c7f8824 100644 --- a/src/app/components/ImageCards.tsx +++ b/src/app/components/ImageCards.tsx @@ -5,15 +5,12 @@ import React, { useRef, useEffect, RefObject, - useMemo, - createRef, } from "react"; -import { Heading, Text } from "@/once-ui/components"; -import Image from "next/image"; +import { Heading, Text, Flex } from "@/once-ui/components"; import imageCardsStyle from "./ImageCards.module.scss"; - import { Card } from "../types"; -import { inherits } from "util"; + +import { ScrollObserver, valueAtPercentage } from '@/app/lib/aat'; const sizeMap: Record = { xs: "var(--static-space-16)", @@ -30,6 +27,7 @@ type ImageCardsProps = { const cardOffset: number = 20; const cardScale: number = 1; + const useCardEffect = ( cardsContainerRef: RefObject, cardsRef: RefObject, @@ -57,14 +55,41 @@ const useCardEffect = ( ); cards?.forEach((card, index) => { - card.style.paddingTop = `${cardOffset + index * cardOffset}px`; + const offsetTop = cardOffset + cardOffset * cardOffset; + card.style.paddingTop = `${cardOffset + index * cardOffset}px`; if (index === cards.length - 1) return; const toScale = cardScale - (cards.length - 1 - index) * 0.1; const nextCard = cards[index + 1]; - const cardInner = card.querySelector(".inner"); + const cardInner = card.children[0] as HTMLElement; + + new ScrollObserver(); + ScrollObserver.Element(nextCard, { + offsetTop: 0, + offsetBottom: window.innerHeight - card.clientHeight, + offsetLeft: 0, + offsetRight: 0, + addWrapper: false, + wrapperClass: '', + container: document.documentElement + + }).onScroll(({ percentageY }) => { + if(cardInner === null) return; + if(cardInner.style === null) return; + cardInner.style.scale = valueAtPercentage({ + from: 1, + to: toScale, + percentage: percentageY + }).toString(); + cardInner.style.filter = `brightness(${valueAtPercentage({ + from: 1, + to: 0.6, + percentage: percentageY + }).toString()})`; + }); + }); return () => {}; - }, [cardsContainerRef, cardsRef]); + }, [cardsContainerRef, cardsRef]); // Work here to ensure useEffect runs ONCE }; const ImageCards = forwardRef( ({ cards }, ref) => { @@ -73,7 +98,16 @@ const ImageCards = forwardRef( useCardEffect(cardContainerRef, cardsRefsById); return ( -
+ +
+
{cards.map((card, i) => (
(
))}
+
+
); }, ); diff --git a/src/app/lib/aat.ts b/src/app/lib/aat.ts new file mode 100644 index 0000000..7f856a6 --- /dev/null +++ b/src/app/lib/aat.ts @@ -0,0 +1,192 @@ +import { clamp } from './helpers'; +export { valueAtPercentage } from './helpers'; + + +interface ScrollObserverOptions { + offsetBottom?: number | (() => number); + offsetTop?: number | (() => number); + offsetRight?: number | (() => number); + offsetLeft?: number | (() => number); + addWrapper?: boolean; + wrapperClass?: string; + container?: HTMLElement | Window; +} + +interface ScrollHandlerParams { + percentageY: number; + percentageX: number; +} + +const defaultOptions: ScrollObserverOptions = { + offsetBottom: 0, + offsetTop: 0, + offsetRight: 0, + offsetLeft: 0, + addWrapper: false, + wrapperClass: '' +}; + +export class ScrollObserver { + protected _handler?: (params: ScrollHandlerParams) => void; + + static Container(container: HTMLElement | Window): ContainerScrollObserver { + return new ContainerScrollObserver(container); + } + + static Element(element: HTMLElement, options: Partial): ElementScrollObserver { + return new ElementScrollObserver(element, { ...defaultOptions, ...options }); + } + + public onScroll(handler: (params: ScrollHandlerParams) => void): void { + this._handler = handler; + this._onScroll(); + } + + protected _onScroll(): void { + // This method is implemented in subclasses. + } +} + +class ContainerScrollObserver extends ScrollObserver { + private readonly _container: HTMLElement | Window; + + constructor(container: HTMLElement | Window) { + super(); + this._container = container; + const scrollElement = container === document.documentElement ? window : container; + scrollElement.addEventListener('scroll', this._onScroll.bind(this)); + } + + protected _onScroll(): void { + const currentScrollY = (this._container as HTMLElement).scrollTop; + const totalScrollY = (this._container as HTMLElement).scrollHeight - (this._container as HTMLElement).clientHeight; + const percentageY = clamp(currentScrollY / totalScrollY, 0, 1) || 0; + + const currentScrollX = (this._container as HTMLElement).scrollLeft; + const totalScrollX = (this._container as HTMLElement).scrollWidth - (this._container as HTMLElement).clientWidth; + const percentageX = clamp(currentScrollX / totalScrollX, 0, 1) || 0; + + if (this._handler && typeof this._handler === 'function') { + requestAnimationFrame(() => this._handler!({ percentageY, percentageX })); + } + } +} + +class ElementScrollObserver extends ScrollObserver { + private readonly _element: HTMLElement; + private readonly _options: ScrollObserverOptions; + private _wrapper?: HTMLElement; + private _lastPercentageY: number | null = null; + private _lastPercentageX: number | null = null; + + constructor(element: HTMLElement, options: ScrollObserverOptions) { + super(); + this._element = element; + this._options = options; + + if (this._options.addWrapper) { + this._addWrapper(); + } + + const scrollContainer = this._options.container === document.documentElement ? window : this._options.container; + scrollContainer?.addEventListener('scroll', this._onScroll.bind(this)); + requestAnimationFrame(() => this._onScroll()); + } + + private _addWrapper(): void { + this._wrapper = document.createElement('div'); + if (this._options.wrapperClass) { + this._wrapper.classList.add(this._options.wrapperClass); + } + this._element.parentNode!.insertBefore(this._wrapper, this._element); + this._wrapper.appendChild(this._element); + } + + private get _containerClientHeight(): number { + return this._options.container === window + ? window.innerHeight + : (this._options.container as HTMLElement).clientHeight; + } + + private get _containerClientWidth(): number { + return this._options.container === window + ? window.innerWidth + : (this._options.container as HTMLElement).clientWidth; + } + + private get _elRectRelativeToContainer(): DOMRect { + const element = this._options.addWrapper ? this._wrapper! : this._element; + const rect: DOMRect = element.getBoundingClientRect(); + if (this._options.container === document.documentElement) { + return rect; + } + const containerRect = (this._options.container as HTMLElement).getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + left: rect.left - containerRect.left, + top: rect.top - containerRect.top, + right: rect.right - containerRect.right, + bottom: rect.bottom - containerRect.bottom, + x: rect.x, + y: rect.y, + toJSON: rect.toJSON.bind(rect) + }; + } + + private getOffsetValue(side: 'Top' | 'Bottom' | 'Left' | 'Right'): number { + const key = `offset${side}` as keyof ScrollObserverOptions; + const value = this._options[key]; + return typeof value === 'function' ? (value as () => number)() : (value as number); + } + + private get _offsetBottom(): number { + return this.getOffsetValue('Bottom'); + } + + private get _offsetTop(): number { + return this.getOffsetValue('Top'); + } + + private get _offsetLeft(): number { + return this.getOffsetValue('Left'); + } + + private get _offsetRight(): number { + return this.getOffsetValue('Right'); + } + + private _calculatePercentageY(): number { + const rect = this._elRectRelativeToContainer; + const startPoint = this._containerClientHeight - this._offsetBottom; + const endPoint = this._offsetTop; + + const viewHeight = startPoint - endPoint; + + return clamp((startPoint - rect.top) / viewHeight, 0, 1); + } + + private _calculatePercentageX(): number { + const rect = this._elRectRelativeToContainer; + const startPoint = this._containerClientWidth - this._offsetRight; + const endPoint = this._offsetLeft; + + const viewWidth = startPoint - endPoint; + + return clamp((startPoint - rect.left) / viewWidth, 0, 1); + } + + protected _onScroll(): void { + const percentageY = this._calculatePercentageY(); + const percentageX = this._calculatePercentageX(); + if ( + this._handler && + typeof this._handler === 'function' && + (this._lastPercentageY !== percentageY || this._lastPercentageX !== percentageX) + ) { + requestAnimationFrame(() => this._handler!({ percentageY, percentageX })); + } + this._lastPercentageY = percentageY; + this._lastPercentageX = percentageX; + } +} \ No newline at end of file diff --git a/src/app/lib/helpers.ts b/src/app/lib/helpers.ts new file mode 100644 index 0000000..88121ea --- /dev/null +++ b/src/app/lib/helpers.ts @@ -0,0 +1,21 @@ +// TypeScript version of clamp +export function clamp(num: number, min: number, max: number): number { + return Math.min(Math.max(num, min), max); + } + + // TypeScript version of valueAtPercentage + interface ValueAtPercentageParams { + from: number; + to: number; + percentage: number; + unit?: string; // Optional unit parameter + } + + export function valueAtPercentage({ + from, + to, + percentage, + unit = '' + }: ValueAtPercentageParams): string | number { + return from + (to - from) * percentage + unit; + } diff --git a/src/app/lib/utils.ts b/src/app/lib/utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/navbar.tsx b/src/app/navbar.tsx index 916685f..ef8811b 100644 --- a/src/app/navbar.tsx +++ b/src/app/navbar.tsx @@ -8,11 +8,6 @@ export default function Navbar() { title: "Home", description: "", }, - { - href: "/projects", - title: "Projects", - description: "What I've worked on", - }, { href: "mailto:zachdidit@gmail.com", title: "Hire Me", @@ -32,14 +27,14 @@ export default function Navbar() { overflow="hidden" fillWidth direction="row" - alignItems="start" + alignItems="center" flex={1} > + + + - + ); }