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 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' ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+
+ return (
+
+
+
+
+ {media}
+
+ {children ? (
+
+ {children}
+
+ ) : null}
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {scrollHint ? (
+
+ {scrollHint}
+
+ ) : null}
+
+
+
+ );
+};
+
+export default ScrollExpand;
diff --git a/public/grass-field.jpg b/public/grass-field.jpg
new file mode 100644
index 0000000..ba25173
Binary files /dev/null and b/public/grass-field.jpg differ