From d8eb5d7504545ebfea4392781aa656cae1a3b213 Mon Sep 17 00:00:00 2001 From: Niklas Haiden Date: Mon, 28 Sep 2026 21:57:12 +0200 Subject: [PATCH] update landing page with grassy cloud fields --- README.md | 7 +- app/globals.css | 5 + app/page.tsx | 41 ++-- components/ScrollExpand/ScrollExpand.css | 102 +++++++++ components/ScrollExpand/ScrollExpand.d.ts | 28 +++ components/ScrollExpand/ScrollExpand.jsx | 243 ++++++++++++++++++++++ public/grass-field.jpg | Bin 0 -> 2667198 bytes 7 files changed, 402 insertions(+), 24 deletions(-) create mode 100644 components/ScrollExpand/ScrollExpand.css create mode 100644 components/ScrollExpand/ScrollExpand.d.ts create mode 100644 components/ScrollExpand/ScrollExpand.jsx create mode 100644 public/grass-field.jpg diff --git a/README.md b/README.md index e215bc4..7ecbec4 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,8 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next- First, run the development server: ```bash -npm run dev -# or -yarn dev -# or +pnpm install pnpm dev -# or -bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/app/globals.css b/app/globals.css index a90f074..5a424f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,3 +2,8 @@ @tailwind components; @tailwind utilities; + +/* Tone down the bright sky so the white type reads on top of it */ +.woiken-hero .scroll-expand__media { + filter: brightness(0.72) saturate(1.1); +} diff --git a/app/page.tsx b/app/page.tsx index 9809fec..debddde 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,26 +1,31 @@ -"use client"; - -import { motion } from "framer-motion"; +import ScrollExpand from "@/components/ScrollExpand/ScrollExpand"; export default function Home() { return (
-
-
Woiken
-
- - -
-

- Woiken is coming soon. -

-
-
+

+ Woiken +

+

+ A new cloud +

+

coming soon.

+ + +
+ © {new Date().getFullYear()} Woiken +
); } diff --git a/components/ScrollExpand/ScrollExpand.css b/components/ScrollExpand/ScrollExpand.css new file mode 100644 index 0000000..0bd0b5e --- /dev/null +++ b/components/ScrollExpand/ScrollExpand.css @@ -0,0 +1,102 @@ +.scroll-expand { + position: relative; + width: 100%; + height: 100%; +} + +.scroll-expand--scroller { + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + overscroll-behavior: contain; +} + +.scroll-expand--scroller::-webkit-scrollbar { + display: none; +} + +.scroll-expand__track { + position: relative; + width: 100%; +} + +.scroll-expand__stage { + position: sticky; + top: 0; + width: 100%; + overflow: hidden; + --se-title-size: 4rem; +} + +.scroll-expand__frame { + position: absolute; + inset: 0; + clip-path: inset(21% 29% 21% 29% round 24px); + will-change: clip-path; +} + +.scroll-expand__media { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + will-change: transform; + transform-origin: center; + user-select: none; + -webkit-user-drag: none; +} + +.scroll-expand__scrim { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.1) 45%, rgba(0, 0, 0, 0.35)); + opacity: 0; +} + +.scroll-expand__overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 6%; + opacity: 0; + will-change: opacity, transform; +} + +.scroll-expand__title { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6%; + text-align: center; + font-size: var(--se-title-size); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1; + color: #fff; + text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45); + pointer-events: none; + will-change: opacity, transform; +} + +.scroll-expand__hint { + position: absolute; + left: 0; + right: 0; + bottom: 1.25rem; + text-align: center; + font-size: 0.8125rem; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.55); + pointer-events: none; + will-change: opacity, transform; +} diff --git a/components/ScrollExpand/ScrollExpand.d.ts b/components/ScrollExpand/ScrollExpand.d.ts new file mode 100644 index 0000000..db598d3 --- /dev/null +++ b/components/ScrollExpand/ScrollExpand.d.ts @@ -0,0 +1,28 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; + +export interface ScrollExpandProps extends Omit, "title"> { + src?: string; + mediaType?: "image" | "video"; + poster?: string; + alt?: string; + title?: string; + scrollHint?: string; + startWidth?: number; + startHeight?: number; + startRadius?: number; + endRadius?: number; + mediaZoom?: number; + scrollDistance?: number; + holdDistance?: number; + smoothing?: number; + overlayScrim?: number; + useWindowScroll?: boolean; + enabled?: boolean; + children?: ReactNode; + className?: string; + style?: CSSProperties; +} + +declare const ScrollExpand: (props: ScrollExpandProps) => JSX.Element; + +export default ScrollExpand; diff --git a/components/ScrollExpand/ScrollExpand.jsx b/components/ScrollExpand/ScrollExpand.jsx new file mode 100644 index 0000000..4ec8294 --- /dev/null +++ b/components/ScrollExpand/ScrollExpand.jsx @@ -0,0 +1,243 @@ +'use client'; + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +import './ScrollExpand.css'; + +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + +const smoothstep = (edge0, edge1, x) => { + const t = clamp((x - edge0) / (edge1 - edge0 || 1e-6), 0, 1); + return t * t * (3 - 2 * t); +}; + +const ScrollExpand = ({ + src = '', + mediaType = 'image', + poster = '', + alt = '', + title = '', + scrollHint = '', + startWidth = 42, + startHeight = 58, + startRadius = 24, + endRadius = 0, + mediaZoom = 1.35, + scrollDistance = 1.2, + holdDistance = 0.35, + smoothing = 0.1, + overlayScrim = 0.45, + useWindowScroll = false, + enabled = true, + children, + className = '', + style, + ...rest +}) => { + const rootRef = useRef(null); + const trackRef = useRef(null); + const stageRef = useRef(null); + const frameRef = useRef(null); + const mediaRef = useRef(null); + const titleRef = useRef(null); + const overlayRef = useRef(null); + const scrimRef = useRef(null); + const hintRef = useRef(null); + + const propsRef = useRef({}); + useLayoutEffect(() => { + propsRef.current = { + startWidth, + startHeight, + startRadius, + endRadius, + mediaZoom, + scrollDistance, + holdDistance, + smoothing, + overlayScrim, + useWindowScroll, + enabled + }; + }); + + const applyProgress = useCallback(p => { + const frame = frameRef.current; + const media = mediaRef.current; + if (!frame || !media) return; + const c = propsRef.current; + + const e = smoothstep(0, 1, p); + + const w = c.startWidth + (100 - c.startWidth) * e; + const h = c.startHeight + (100 - c.startHeight) * e; + const ix = Math.max(0, (100 - w) / 2); + const iy = Math.max(0, (100 - h) / 2); + const r = c.startRadius + (c.endRadius - c.startRadius) * e; + frame.style.clipPath = `inset(${iy}% ${ix}% ${iy}% ${ix}% round ${r}px)`; + + media.style.transform = `scale(${c.mediaZoom + (1 - c.mediaZoom) * e})`; + + if (scrimRef.current) scrimRef.current.style.opacity = `${c.overlayScrim * e}`; + + if (titleRef.current) { + const out = smoothstep(0.4, 0.88, p); + titleRef.current.style.opacity = `${1 - out}`; + titleRef.current.style.transform = `translate3d(0, ${-28 * out}px, 0) scale(${1 + 0.06 * out})`; + } + + if (hintRef.current) { + const gone = smoothstep(0, 0.12, p); + hintRef.current.style.opacity = `${1 - gone}`; + hintRef.current.style.transform = `translate3d(0, ${8 * gone}px, 0)`; + } + + if (overlayRef.current) { + const inn = smoothstep(0.68, 1, p); + overlayRef.current.style.opacity = `${inn}`; + overlayRef.current.style.transform = `translate3d(0, ${18 * (1 - inn)}px, 0)`; + } + }, []); + + useEffect(() => { + const root = rootRef.current; + const track = trackRef.current; + const stage = stageRef.current; + if (!root || !track || !stage) return; + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + let raf = 0; + let current = 0; + let target = 0; + let stageH = 0; + let running = false; + + const measure = () => { + const c = propsRef.current; + stageH = c.useWindowScroll ? window.innerHeight : root.clientHeight; + if (stageH <= 0) return; + stage.style.height = `${stageH}px`; + track.style.height = `${stageH * (1 + Math.max(0, c.scrollDistance) + Math.max(0, c.holdDistance))}px`; + + const w = root.clientWidth || stageH; + stage.style.setProperty('--se-title-size', `${clamp(w * 0.075, 20, 84)}px`); + }; + + const readProgress = () => { + const c = propsRef.current; + if (!c.enabled) return 1; + const span = stageH * Math.max(0.01, c.scrollDistance); + if (c.useWindowScroll) { + const top = track.getBoundingClientRect().top; + return clamp(-top / span, 0, 1); + } + return clamp(root.scrollTop / span, 0, 1); + }; + + const tick = () => { + const c = propsRef.current; + const k = c.smoothing <= 0 ? 1 : 1 - Math.exp(-1 / (60 * c.smoothing)); + current += (target - current) * k; + if (Math.abs(target - current) < 0.0004) { + current = target; + running = false; + } + applyProgress(current); + raf = running ? requestAnimationFrame(tick) : 0; + }; + + const kick = () => { + if (running) return; + running = true; + if (!raf) raf = requestAnimationFrame(tick); + }; + + const onScroll = () => { + target = readProgress(); + if (propsRef.current.smoothing <= 0 || reduceMotion) { + current = target; + applyProgress(current); + return; + } + kick(); + }; + + const onResize = () => { + measure(); + target = readProgress(); + current = target; + applyProgress(current); + }; + + measure(); + target = readProgress(); + current = target; + applyProgress(current); + + const scroller = useWindowScroll ? window : root; + scroller.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onResize); + const ro = new ResizeObserver(onResize); + ro.observe(root); + + return () => { + if (raf) cancelAnimationFrame(raf); + scroller.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onResize); + ro.disconnect(); + }; + }, [applyProgress, useWindowScroll]); + + const media = + mediaType === 'video' ? ( +