-
-
} />
+
+
+
+
+ {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;
+};