diff --git a/site/src/common/blocks/MediaGalleryBlock.tsx b/site/src/common/blocks/MediaGalleryBlock.tsx index c699e4e21..a22a30cf5 100644 --- a/site/src/common/blocks/MediaGalleryBlock.tsx +++ b/site/src/common/blocks/MediaGalleryBlock.tsx @@ -6,6 +6,7 @@ import { type MediaGalleryBlockData } from "@src/blocks.generated"; import { MediaBlock } from "@src/common/blocks/MediaBlock"; import { Typography } from "@src/common/components/Typography"; import { PageLayout } from "@src/layout/PageLayout"; +import { FadeBoxInOnScroll } from "@src/util/animations/FadeBoxInOnScroll"; import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; import { useIntl } from "react-intl"; @@ -92,7 +93,9 @@ export const MediaGalleryBlock = withPreview( export const PageContentMediaGalleryBlock = (props: MediaGalleryBlockProps) => (
- + + +
); diff --git a/site/src/documents/pages/blocks/BasicStageBlock.tsx b/site/src/documents/pages/blocks/BasicStageBlock.tsx index 429e5972a..eeb6c0fbd 100644 --- a/site/src/documents/pages/blocks/BasicStageBlock.tsx +++ b/site/src/documents/pages/blocks/BasicStageBlock.tsx @@ -6,6 +6,7 @@ import { HeadingBlock } from "@src/common/blocks/HeadingBlock"; import { MediaBlock } from "@src/common/blocks/MediaBlock"; import { RichTextBlock } from "@src/common/blocks/RichTextBlock"; import { PageLayout } from "@src/layout/PageLayout"; +import { FadeBoxInOnLoad } from "@src/util/animations/FadeBoxInOnLoad"; import styles from "./BasicStageBlock.module.scss"; @@ -27,9 +28,15 @@ export const BasicStageBlock = withPreview(
- - - + + + + + + + + +
diff --git a/site/src/documents/pages/blocks/BillboardTeaserBlock.module.scss b/site/src/documents/pages/blocks/BillboardTeaserBlock.module.scss index f0b0789e7..bf13fdae4 100644 --- a/site/src/documents/pages/blocks/BillboardTeaserBlock.module.scss +++ b/site/src/documents/pages/blocks/BillboardTeaserBlock.module.scss @@ -28,6 +28,7 @@ flex-direction: column; justify-content: center; align-items: center; + text-align: center; color: var(--text-primary); grid-column: 3 / -3; @@ -40,6 +41,13 @@ } } +.callToActionWrapper { + display: flex; + flex-direction: column; + align-items: center; +} + + .imageMobile { @media (min-width: $breakpoint-sm) { display: none; diff --git a/site/src/documents/pages/blocks/BillboardTeaserBlock.tsx b/site/src/documents/pages/blocks/BillboardTeaserBlock.tsx index 119072cc6..7c5ad5f67 100644 --- a/site/src/documents/pages/blocks/BillboardTeaserBlock.tsx +++ b/site/src/documents/pages/blocks/BillboardTeaserBlock.tsx @@ -5,33 +5,44 @@ import { HeadingBlock } from "@src/common/blocks/HeadingBlock"; import { MediaBlock } from "@src/common/blocks/MediaBlock"; import { RichTextBlock } from "@src/common/blocks/RichTextBlock"; import { PageLayout } from "@src/layout/PageLayout"; +import { FadeBoxInOnScroll } from "@src/util/animations/FadeBoxInOnScroll"; import styles from "./BillboardTeaserBlock.module.scss"; export const BillboardTeaserBlock = withPreview( - ({ data: { media, heading, text, overlay, callToActionList } }: PropsWithData) => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
- - - + ({ data: { media, heading, text, overlay, callToActionList } }: PropsWithData) => { + return ( +
+
+
- -
- ), +
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + +
+ +
+
+
+
+
+ ); + }, { label: "Billboard Teaser" }, ); diff --git a/site/src/documents/pages/blocks/ColumnsBlock.tsx b/site/src/documents/pages/blocks/ColumnsBlock.tsx index 62961bd61..f121898f7 100644 --- a/site/src/documents/pages/blocks/ColumnsBlock.tsx +++ b/site/src/documents/pages/blocks/ColumnsBlock.tsx @@ -9,6 +9,7 @@ import { StandaloneHeadingBlock } from "@src/common/blocks/StandaloneHeadingBloc import { StandaloneMediaBlock } from "@src/common/blocks/StandaloneMediaBlock"; import { StandaloneRichTextBlock } from "@src/common/blocks/StandaloneRichTextBlock"; import { PageLayout } from "@src/layout/PageLayout"; +import { FadeBoxInOnScroll } from "@src/util/animations/FadeBoxInOnScroll"; import clsx from "clsx"; import styles from "./ColumnsBlock.module.scss"; @@ -42,9 +43,11 @@ const layoutToStyleMap: { [key: string]: string } = { export const ColumnsBlock = withPreview( ({ data: { columns, layout } }: PropsWithData) => ( - {columns.map((column) => ( + {columns.map((column, index) => (
- + + +
))}
diff --git a/site/src/documents/pages/blocks/TeaserBlock.tsx b/site/src/documents/pages/blocks/TeaserBlock.tsx index a44bd2b60..1fb940a9a 100644 --- a/site/src/documents/pages/blocks/TeaserBlock.tsx +++ b/site/src/documents/pages/blocks/TeaserBlock.tsx @@ -1,19 +1,27 @@ -import { ListBlock, type PropsWithData, withPreview } from "@comet/site-nextjs"; +import { type PropsWithData, withPreview } from "@comet/site-nextjs"; import { type TeaserBlockData } from "@src/blocks.generated"; import { PageLayout } from "@src/layout/PageLayout"; +import { FadeBoxInOnScroll } from "@src/util/animations/FadeBoxInOnScroll"; +import { FadeGroup } from "@src/util/animations/FadeGroup"; import styles from "./TeaserBlock.module.scss"; import { TeaserItemBlock } from "./TeaserItemBlock"; export const TeaserBlock = withPreview( ({ data }: PropsWithData) => ( - -
-
- } /> + + +
+
+ {data.blocks.map((block, index) => ( + + + + ))} +
-
- + + ), { label: "Teaser" }, ); diff --git a/site/src/layout/header/DesktopMenu.module.scss b/site/src/layout/header/DesktopMenu.module.scss index a4bfa8d95..962259856 100644 --- a/site/src/layout/header/DesktopMenu.module.scss +++ b/site/src/layout/header/DesktopMenu.module.scss @@ -19,7 +19,7 @@ } .subLevelNavigation { - display: none; + display: flex; flex-direction: column; gap: var(--spacing-s200); position: absolute; @@ -33,9 +33,15 @@ border-left: 1px solid var(--brand-grey-200); border-bottom: 1px solid var(--brand-grey-200); border-right: 1px solid var(--brand-grey-200); + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + visibility: hidden; &--expanded { - display: flex; + opacity: 1; + pointer-events: auto; + visibility: visible; } } diff --git a/site/src/styles/global.scss b/site/src/styles/global.scss index f8f6b92f8..b8b475d05 100644 --- a/site/src/styles/global.scss +++ b/site/src/styles/global.scss @@ -45,6 +45,7 @@ html { /* Prevent font size adjustments after orientation changes in mobile devices */ + scroll-behavior: smooth; text-size-adjust: 100%; --header-height: 100px; diff --git a/site/src/util/animations/FadeBoxInOnLoad.module.scss b/site/src/util/animations/FadeBoxInOnLoad.module.scss new file mode 100644 index 000000000..9efd0e02a --- /dev/null +++ b/site/src/util/animations/FadeBoxInOnLoad.module.scss @@ -0,0 +1,92 @@ +@keyframes fade-in-from-left { + from { + opacity: 0; + transform: translate3d(-30px, 0, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes fade-in-from-right { + from { + opacity: 0; + transform: translate3d(30px, 0, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes fade-in-from-top { + from { + opacity: 0; + transform: translate3d(0, -30px, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes fade-in-from-bottom { + from { + opacity: 0; + transform: translate3d(0, 30px, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.root { + animation-duration: var(--fade-duration, 0.8s); + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + animation-fill-mode: both; + animation-delay: var(--fade-delay, 0ms); +} + +.fromLeft { + animation-name: fade-in-from-left; +} + +.fromRight { + animation-name: fade-in-from-right; +} + +.fromTop { + animation-name: fade-in-from-top; +} + +.fromBottom { + animation-name: fade-in-from-bottom; +} + +.fadeIn { + animation-name: fade-in; +} + +@media (prefers-reduced-motion: reduce) { + .root { + animation: none; + opacity: 1; + transform: translate3d(0, 0, 0); + } +} diff --git a/site/src/util/animations/FadeBoxInOnLoad.tsx b/site/src/util/animations/FadeBoxInOnLoad.tsx new file mode 100644 index 000000000..f03295f70 --- /dev/null +++ b/site/src/util/animations/FadeBoxInOnLoad.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { usePreview } from "@comet/site-nextjs"; +import clsx from "clsx"; +import { type ReactElement } from "react"; + +import styles from "./FadeBoxInOnLoad.module.scss"; + +interface FadeBoxInOnLoadProps { + direction?: "top" | "right" | "bottom" | "left"; + children: ReactElement; + delay?: number; + duration?: number; +} + +export function FadeBoxInOnLoad({ children, direction = undefined, delay = 0, duration }: FadeBoxInOnLoadProps) { + const { previewType } = usePreview(); + + const style = { + "--fade-delay": `${delay}ms`, + ...(duration != null && { "--fade-duration": `${duration}ms` }), + } as React.CSSProperties; + + return ( +
+ {children} +
+ ); +} diff --git a/site/src/util/animations/FadeBoxInOnScroll.module.scss b/site/src/util/animations/FadeBoxInOnScroll.module.scss new file mode 100644 index 000000000..daab61d36 --- /dev/null +++ b/site/src/util/animations/FadeBoxInOnScroll.module.scss @@ -0,0 +1,39 @@ +.scrollContainer { + opacity: 0; + transition: + opacity var(--fade-duration, 500ms) ease-in-out var(--fade-delay, 0ms), + transform 1s cubic-bezier(0.22, 1, 0.36, 1) var(--fade-delay, 0ms); +} + +.fullHeight { + height: 100%; +} + +.fromLeft { + transform: translate3d(-40px, 0, 0); +} + +.fromRight { + transform: translate3d(40px, 0, 0); +} + +.fromTop { + transform: translate3d(0, -40px, 0); +} + +.fromBottom { + transform: translate3d(0, 40px, 0); +} + +.fadeIn { + opacity: 1; + transform: translate3d(0, 0, 0); +} + +@media (prefers-reduced-motion: reduce) { + .scrollContainer { + opacity: 1; + transform: translate3d(0, 0, 0); + transition: none; + } +} diff --git a/site/src/util/animations/FadeBoxInOnScroll.tsx b/site/src/util/animations/FadeBoxInOnScroll.tsx new file mode 100644 index 000000000..9b9a9e4e7 --- /dev/null +++ b/site/src/util/animations/FadeBoxInOnScroll.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { usePreview } from "@comet/site-nextjs"; +import { useFadeGroup } from "@src/util/animations/FadeGroup"; +import { useGlobalScrollSpeed } from "@src/util/animations/useGlobalScrollSpeed"; +import { useWindowSize } from "@src/util/useWindowSize"; +import clsx from "clsx"; +import { type ReactElement, useEffect, useRef, useState } from "react"; + +import styles from "./FadeBoxInOnScroll.module.scss"; + +interface FadeBoxInOnScrollProps { + direction?: "top" | "right" | "bottom" | "left" | undefined; + children: ReactElement; + offset?: number; + delay?: number; + fullHeight?: boolean; + onChange?: (inView: boolean) => void; + className?: string; + innerClassName?: string; +} + +export function FadeBoxInOnScroll({ + children, + direction = undefined, + offset = 200, + delay = 0, + fullHeight = false, + onChange, + className, + innerClassName, + ...props +}: FadeBoxInOnScrollProps) { + const fadeGroup = useFadeGroup(); + const refScrollContainer = useRef(null); + const [fadeIn, setFadeIn] = useState(false); + const { previewType } = usePreview(); + const windowSize = useWindowSize(); + const scrollSpeed = useGlobalScrollSpeed(); + + const groupForceVisible = fadeGroup?.visible ?? false; + const groupOnVisible = fadeGroup?.onVisible; + const groupDisabled = fadeGroup?.disabled ?? false; + + // When FadeGroup is used and disabled in some breakpoints, + // the delay should be 0 to avoid raised delay on elements below each other + const effectiveDelay = groupDisabled ? 0 : delay; + + // Dynamic delay and fade duration for speedup fade in on faster scrolling + const dynamicDelay = scrollSpeed > 1 ? effectiveDelay / (scrollSpeed / 2) : effectiveDelay; + const dynamicFadeDuration = scrollSpeed > 1 ? Math.min(500 / (scrollSpeed / 2), 200) : 500; + + useEffect(() => { + const scrollContainer = refScrollContainer.current; + if (!scrollContainer || previewType === "BlockPreview") return; + + // Dynamic offset for trigger fade-in earlier on faster scrolling + const dynamicOffsetScrollSpeed = scrollSpeed > 1 ? scrollSpeed * 100 : 0; + // Dynamic offset page height for adjusting offset relative to page height + const dynamicOffsetPageHeight = windowSize ? (windowSize?.height / 2.5) * -1 + offset : offset; + const fadeInOffset = dynamicOffsetScrollSpeed + dynamicOffsetPageHeight; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setFadeIn(true); + onChange?.(entry.isIntersecting); + if (!groupDisabled) groupOnVisible?.(); + } + }); + }, + { + rootMargin: `0px 0px ${direction === "bottom" ? fadeInOffset + 40 : direction === "top" ? fadeInOffset - 40 : fadeInOffset}px 0px`, + threshold: 0, + }, + ); + + observer.observe(scrollContainer); + + return () => { + if (scrollContainer) { + observer.unobserve(scrollContainer); + } + }; + }, [offset, previewType, direction, windowSize, onChange, scrollSpeed, groupOnVisible, groupDisabled]); + + // Set CSS variable for delay and duration + const style = { "--fade-delay": `${dynamicDelay ?? 0}ms`, "--fade-duration": `${dynamicFadeDuration ?? 0}ms` } as React.CSSProperties; + + return ( +
{ + setFadeIn(true); + groupOnVisible?.(); + }} + className={clsx( + styles.scrollContainer, + fullHeight && styles.fullHeight, + direction === "left" && styles.fromLeft, + direction === "right" && styles.fromRight, + direction === "top" && styles.fromTop, + direction === "bottom" && styles.fromBottom, + (previewType === "BlockPreview" || fadeIn || groupForceVisible) && styles.fadeIn, + innerClassName, + )} + style={style} + {...props} + > + {children} +
+ ); +} diff --git a/site/src/util/animations/FadeGroup.tsx b/site/src/util/animations/FadeGroup.tsx new file mode 100644 index 000000000..355286755 --- /dev/null +++ b/site/src/util/animations/FadeGroup.tsx @@ -0,0 +1,64 @@ +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; + +const breakpoints = { + xs: 0, + sm: 600, + md: 900, + lg: 1200, + xl: 1600, +}; + +interface FadeGroupContextValue { + visible: boolean; + onVisible: () => void; + disabled: boolean; +} + +const FadeGroupContext = createContext(null); + +export function useFadeGroup() { + return useContext(FadeGroupContext); +} + +type BreakpointKey = keyof typeof breakpoints; + +function getDisabledRanges(disabledBreakpoints: BreakpointKey[]) { + const keys = Object.keys(breakpoints) as BreakpointKey[]; + const sorted = [...disabledBreakpoints].sort((a, b) => breakpoints[a] - breakpoints[b]); + const ranges: Array<[number, number]> = []; + + for (const breakpoint of sorted) { + const idx = keys.indexOf(breakpoint); + if (idx < keys.length - 1) { + // Disable from this breakpoint up to the next one + ranges.push([breakpoints[breakpoint], breakpoints[keys[idx + 1]]]); + } else { + // Last breakpoint: disable from its value to Infinity + ranges.push([breakpoints[breakpoint], Infinity]); + } + } + return ranges; +} + +function isInDisabledRange(width: number, ranges: Array<[number, number]>) { + return ranges.some(([min, max]) => width >= min && width < max); +} + +export function FadeGroup({ children, disabledBreakpoints = [] }: { children: ReactNode; disabledBreakpoints?: BreakpointKey[] }) { + const [visible, setVisible] = useState(false); + const onVisible = useCallback(() => setVisible(true), []); + const [disableFadeGroup, setDisableFadeGroup] = useState(false); + + useEffect(() => { + function checkDisabledBreakpoints() { + const width = window.innerWidth; + const ranges = getDisabledRanges(disabledBreakpoints); + setDisableFadeGroup(isInDisabledRange(width, ranges)); + } + checkDisabledBreakpoints(); + window.addEventListener("resize", checkDisabledBreakpoints); + return () => window.removeEventListener("resize", checkDisabledBreakpoints); + }, [disabledBreakpoints]); + + return {children}; +} diff --git a/site/src/util/animations/useGlobalScrollSpeed.ts b/site/src/util/animations/useGlobalScrollSpeed.ts new file mode 100644 index 000000000..5395e4081 --- /dev/null +++ b/site/src/util/animations/useGlobalScrollSpeed.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +const listeners: Set<(speed: number) => void> = new Set(); +let lastScrollY = window.scrollY; +let lastTime = Date.now(); + +function notifyListeners(speed: number) { + listeners.forEach((cb) => cb(speed)); +} + +function onScroll() { + const now = Date.now(); + const newY = window.scrollY; + const deltaY = Math.abs(newY - lastScrollY); + const deltaTime = now - lastTime; + const speed = deltaY / (deltaTime || 1); + notifyListeners(speed); + lastScrollY = newY; + lastTime = now; +} + +export function useGlobalScrollSpeed() { + const [speed, setSpeed] = useState(0); + const setSpeedRef = useRef(setSpeed); + + useEffect(() => { + // Update ref to always have the latest callback + setSpeedRef.current = setSpeed; + }, [setSpeed]); + + useEffect(() => { + const callback = (newSpeed: number) => { + setSpeedRef.current(newSpeed); + }; + + // Only add event listener if this is the first listener + if (listeners.size === 0) { + window.addEventListener("scroll", onScroll, { passive: true }); + } + + // Use Set to prevent duplicates + listeners.add(callback); + + return () => { + listeners.delete(callback); + // Remove event listener when no more listeners + if (listeners.size === 0) { + window.removeEventListener("scroll", onScroll); + } + }; + }, []); + + return speed; +} diff --git a/site/src/util/useWindowSize.ts b/site/src/util/useWindowSize.ts new file mode 100644 index 000000000..114722767 --- /dev/null +++ b/site/src/util/useWindowSize.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +interface WindowSize { + width: number; + height: number; +} + +export const useWindowSize = (): WindowSize | undefined => { + const [windowSize, setWindowSize] = useState(); + + useEffect(() => { + const getSize = (): WindowSize => ({ + width: window.innerWidth, + height: window.innerHeight, + }); + + setWindowSize(getSize()); + + const handleResize = () => { + setWindowSize(getSize()); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return windowSize; +};