higgsfield-ai / skills
higgsfield-websites
Build, edit, and deploy full-stack websites, apps and games via the Higgsfield CLI (`higgsfield website …`). Each is a React 19 + TanStack Start SSR app in one Cloudflare Worker (D1/R2/KV/DO/Containers). THREE product types, picked via `--type` on create: `website` (standalone, no Higgsfield integration — references/website-flow.md), `app` (Sign in with Higgsfield + fnf SDK, Quanta — references/app-flow.md), `game` (realtime multiplayer rooms — references/game-flow.md). Routes to the right flow;
Install
$
npx skills add https://github.com/higgsfield-ai/skills --skill higgsfield-websitesScroll scrub React asset
Read references/scroll-scrub.md first. Copy the fenced source into
app/src/components/scroll-scrub/scroll-scrub.tsx, then adapt scene data and
composition without weakening the runtime invariants.
tsx
/* Scroll scrub React/TanStack reference implementation. */
import { useEffect, useMemo, useRef, useState } from "react";import type { CSSProperties, ReactNode } from "react";
import "./scroll-scrub.css";
export interface ScrollScrubScene { id: string; label: string; /** Exact first frame of the deployed desktop clip. */ poster: string; /** Exact first frame of mobileClip; provide whenever mobileClip is set. */ mobilePoster?: string; clip: string; mobileClip?: string; title: string; body: string; kicker?: string; tags?: string[]; actions?: ReactNode; align?: "left" | "right"; /** Viewport-heights assigned to this scene. More distance means slower scrub. */ scroll?: number; /** 0..0.6. Slow the middle of the clip without changing either seam frame. */ linger?: number; objectPosition?: string; mobileObjectPosition?: string;}
export interface ScrollScrubConnector { /** Exact first frame of this connector clip; never substitute a scene still. */ poster: string; /** Exact first frame of mobileClip; provide whenever mobileClip is set. */ mobilePoster?: string; clip: string; mobileClip?: string; scroll?: number;}
export interface ScrollScrubTheme { background: string; ink: string; muted: string; accent: string;}
export interface ScrollScrubProps { scenes: ScrollScrubScene[]; /** Leave empty for continuous-forward architecture A. */ connectors?: (ScrollScrubConnector | null)[]; theme: ScrollScrubTheme; className?: string; onActiveSectionChange?: (index: number) => void;}
interface Segment { key: string; kind: "scene" | "connector"; sectionIndex: number; nextSectionIndex: number; poster: string; mobilePoster?: string; clip: string; mobileClip?: string; weight: number; linger: number; objectPosition: string; mobileObjectPosition: string; scene?: ScrollScrubScene;}
interface RuntimeSegment extends Segment { band: HTMLElement; layer: HTMLElement; start: number; end: number; current: number; target: number; visible: boolean; loading: boolean; ready: boolean; failed: boolean; loadedSource?: string; video?: HTMLVideoElement; objectUrl?: string; abort?: AbortController;}
interface Controller { jumpToSection: (index: number) => void;}
type ThemeStyle = CSSProperties & Record<`--ss-${string}`, string | number>;
const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const smoothstep = (value: number) => { const x = clamp(value); return x * x * (3 - 2 * x);};
const lingerEase = (value: number, amount: number) => { const x = clamp(value); const linger = clamp(amount, 0, 0.6); const centered = x - 0.5; return (1 - linger) * x + linger * (4 * centered ** 3 + 0.5);};
function buildSegments( scenes: ScrollScrubScene[], connectors: (ScrollScrubConnector | null)[]): Segment[] { const result: Segment[] = [];
for (const [index, scene] of scenes.entries()) { if (scene.mobileClip && !scene.mobilePoster) { throw new Error(`Scene ${scene.id} needs mobilePoster for mobileClip`); } result.push({ clip: scene.clip, key: `scene:${scene.id}`, kind: "scene", linger: scene.linger ?? 0, mobileClip: scene.mobileClip, mobilePoster: scene.mobilePoster, mobileObjectPosition: scene.mobileObjectPosition ?? scene.objectPosition ?? "50% 50%", nextSectionIndex: index, objectPosition: scene.objectPosition ?? "50% 50%", poster: scene.poster, scene, sectionIndex: index, weight: scene.scroll ?? 1.4, });
const connector = connectors[index]; if (index < scenes.length - 1 && connector?.clip) { if (connector.mobileClip && !connector.mobilePoster) { throw new Error( `Connector after ${scene.id} needs mobilePoster for mobileClip` ); } const nextScene = scenes[index + 1]; result.push({ clip: connector.clip, key: `connector:${scene.id}:${nextScene.id}`, kind: "connector", linger: 0, mobileClip: connector.mobileClip, mobilePoster: connector.mobilePoster, mobileObjectPosition: nextScene.mobileObjectPosition ?? nextScene.objectPosition ?? "50% 50%", nextSectionIndex: index + 1, objectPosition: nextScene.objectPosition ?? "50% 50%", poster: connector.poster, sectionIndex: index, weight: connector.scroll ?? 0.8, }); } }
return result;}
export function ScrollScrub({ scenes, connectors, theme, className, onActiveSectionChange,}: ScrollScrubProps) { const rootRef = useRef<HTMLElement>(null); const controllerRef = useRef<Controller | null>(null); const onActiveRef = useRef(onActiveSectionChange); const [activeSection, setActiveSection] = useState(0); const segments = useMemo( () => buildSegments(scenes, connectors ?? []), [connectors, scenes] );
onActiveRef.current = onActiveSectionChange;
useEffect(() => { const root = rootRef.current; if (!root || segments.length === 0) { return; }
const layerNodes = [ ...root.querySelectorAll<HTMLElement>("[data-scroll-scrub-layer]"), ]; const bandNodes = [ ...root.querySelectorAll<HTMLElement>("[data-scroll-scrub-band]"), ]; if ( layerNodes.length !== segments.length || bandNodes.length !== segments.length ) { throw new Error("ScrollScrub segment markup is out of sync"); }
const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; const coarsePointer = window.matchMedia( "(hover: none) and (pointer: coarse)" ).matches; const smallViewport = window.matchMedia("(max-width: 860px)"); const isMobile = () => coarsePointer || smallViewport.matches; const sourceFor = (segment: RuntimeSegment) => isMobile() && segment.mobileClip ? segment.mobileClip : segment.clip; const runtime: RuntimeSegment[] = segments.map((segment, index) => ({ ...segment, band: bandNodes[index], current: 0, end: 0, failed: false, layer: layerNodes[index], loading: false, ready: false, start: 0, target: 0, visible: index === 0, }));
let active = -1; let destroyed = false; let dirty = true; let frame = 0; let rootTop = 0; let total = 1; let viewportHeight = window.innerHeight; let layoutWidth = window.innerWidth; let userReady = false;
const unloadClip = (segment: RuntimeSegment) => { segment.abort?.abort(); segment.video?.remove(); if (segment.objectUrl) { URL.revokeObjectURL(segment.objectUrl); } delete segment.abort; delete segment.video; delete segment.objectUrl; delete segment.loadedSource; segment.loading = false; segment.ready = false; segment.failed = false; segment.current = segment.target; delete segment.layer.dataset.videoPainted; delete segment.layer.dataset.videoFailed; };
const layout = () => { const pageY = window.scrollY || window.pageYOffset; rootTop = root.getBoundingClientRect().top + pageY; viewportHeight = window.innerHeight; layoutWidth = window.innerWidth;
for (const segment of runtime) { if ( segment.loadedSource && segment.loadedSource !== sourceFor(segment) ) { unloadClip(segment); } const rect = segment.band.getBoundingClientRect(); segment.start = rect.top + pageY - rootTop; segment.end = segment.start + rect.height; } total = Math.max(runtime.at(-1)?.end ?? viewportHeight, viewportHeight); dirty = true; };
const primeVideo = async (video?: HTMLVideoElement) => { if (!video || !isMobile()) { return; } try { await video.play(); video.pause(); } catch { // Keep the poster; a later user gesture/seek can retry naturally. } };
const loadClip = async (segment: RuntimeSegment) => { const source = sourceFor(segment); if ( reduceMotion || destroyed || segment.loading || segment.ready || segment.failed || !source ) { return; }
segment.loading = true; segment.loadedSource = source; segment.abort = new AbortController(); const request = segment.abort;
try { const response = await fetch(source, { signal: request.signal, }); if (!response.ok) { throw new Error(`Clip failed: ${response.status}`); } const blob = await response.blob(); if ( destroyed || request.signal.aborted || segment.loadedSource !== source ) { return; }
const objectUrl = URL.createObjectURL(blob); const video = document.createElement("video"); video.className = "scroll-scrub__video"; video.muted = true; video.playsInline = true; video.preload = "auto"; video.setAttribute("muted", ""); video.setAttribute("playsinline", ""); video.src = objectUrl;
video.addEventListener( "loadedmetadata", () => { if (segment.video !== video || segment.loadedSource !== source) { return; } segment.ready = true; segment.loading = false; dirty = true; }, { once: true } ); video.addEventListener( "loadeddata", () => { if ( userReady && segment.video === video && segment.loadedSource === source ) { void primeVideo(video); } }, { once: true } ); video.addEventListener( "error", () => { if (segment.video !== video) { return; } video.remove(); URL.revokeObjectURL(objectUrl); delete segment.video; delete segment.objectUrl; segment.failed = true; segment.loading = false; segment.ready = false; delete segment.layer.dataset.videoPainted; segment.layer.dataset.videoFailed = "true"; }, { once: true } ); video.addEventListener( "seeked", () => { if (segment.video === video && segment.loadedSource === source) { segment.layer.dataset.videoPainted = "true"; } }, { once: true } );
segment.layer.append(video); segment.objectUrl = objectUrl; segment.video = video; } catch (error) { if ( request.signal.aborted || (error instanceof Error && error.name === "AbortError") || segment.loadedSource !== source ) { return; } segment.layer.dataset.videoFailed = "true"; segment.failed = true; segment.loading = false; } };
const readScroll = () => { const pageY = window.scrollY || window.pageYOffset; const y = clamp(pageY - rootTop, 0, total); const crossfade = 0.1 * viewportHeight; let currentIndex = 0;
for (const [index, segment] of runtime.entries()) { if (y >= segment.start) { currentIndex = index; }
const length = Math.max(segment.end - segment.start, 1); const local = clamp((y - segment.start) / length); segment.target = segment.linger ? lingerEase(local, segment.linger) : local;
let outside = 0; if (y < segment.start) { outside = segment.start - y; } if (y > segment.end) { outside = y - segment.end; } let opacity = smoothstep(1 - outside / Math.max(crossfade, 1)); if (reduceMotion) { opacity = outside === 0 ? 1 : 0; }
segment.visible = opacity > 0.001; segment.layer.style.opacity = String(opacity); segment.layer.style.zIndex = index === currentIndex ? "2" : "1";
if ( y > segment.start - 1.5 * viewportHeight && y < segment.end + 1.5 * viewportHeight ) { void loadClip(segment); } }
const current = runtime[currentIndex]; const currentLength = Math.max(current.end - current.start, 1); const currentProgress = clamp((y - current.start) / currentLength); const nextActive = current.kind === "connector" && currentProgress >= 0.5 ? current.nextSectionIndex : current.sectionIndex;
if (nextActive !== active) { active = nextActive; root.dataset.activeSection = String(active); setActiveSection(active); onActiveRef.current?.(active); }
root.style.setProperty("--ss-progress", String(clamp(y / total))); };
const updateVideos = () => { for (const segment of runtime) { const { video } = segment; if (!video || !segment.ready || video.seeking) { continue; } if ( !segment.visible && Math.abs(segment.current - segment.target) < 0.002 ) { continue; }
segment.current += (segment.target - segment.current) * 0.2; const targetTime = clamp(segment.current, 0, 0.999) * (video.duration || 1); const epsilon = isMobile() ? 0.02 : 0.008; if (Math.abs(video.currentTime - targetTime) > epsilon) { try { video.currentTime = targetTime; } catch { // Keep the last painted frame while the browser catches up. } } } };
const tick = () => { if (destroyed) { return; } if (dirty) { dirty = false; readScroll(); } updateVideos(); frame = window.requestAnimationFrame(tick); };
const onScroll = () => { dirty = true; }; const onResize = () => { if (coarsePointer && window.innerWidth === layoutWidth) { return; } layout(); }; const onFirstGesture = () => { if (userReady) { return; } userReady = true; for (const segment of runtime) { void primeVideo(segment.video); } };
controllerRef.current = { jumpToSection(index) { const segment = runtime.find( (candidate) => candidate.kind === "scene" && candidate.sectionIndex === index ); if (!segment) { return; } const top = rootTop + segment.start + 0.15 * (segment.end - segment.start); window.scrollTo({ behavior: reduceMotion ? "auto" : "smooth", top, }); }, };
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onResize); window.addEventListener("orientationchange", layout); window.addEventListener("pointerdown", onFirstGesture, { once: true, passive: true, }); window.addEventListener("touchstart", onFirstGesture, { once: true, passive: true, });
layout(); frame = window.requestAnimationFrame(tick);
return () => { destroyed = true; controllerRef.current = null; window.cancelAnimationFrame(frame); window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onResize); window.removeEventListener("orientationchange", layout); window.removeEventListener("pointerdown", onFirstGesture); window.removeEventListener("touchstart", onFirstGesture); root.style.removeProperty("--ss-progress"); delete root.dataset.activeSection;
for (const segment of runtime) { unloadClip(segment); segment.layer.style.removeProperty("opacity"); segment.layer.style.removeProperty("z-index"); } }; }, [segments]);
if (scenes.length === 0) { return null; }
const themeStyle: ThemeStyle = { "--ss-accent": theme.accent, "--ss-bg": theme.background, "--ss-ink": theme.ink, "--ss-muted": theme.muted, };
return ( <section className={["scroll-scrub", className].filter(Boolean).join(" ")} ref={rootRef} style={themeStyle} > <div className="scroll-scrub__stage"> <div aria-hidden="true" className="scroll-scrub__media"> {segments.map((segment, index) => { const layerStyle: ThemeStyle = { "--ss-mobile-position": segment.mobileObjectPosition, "--ss-object-position": segment.objectPosition, }; return ( <figure className={`scroll-scrub__layer scroll-scrub__layer--${segment.kind}`} data-scroll-scrub-layer="" key={segment.key} style={layerStyle} > <picture className="scroll-scrub__picture"> {segment.mobilePoster ? ( <source media="(hover: none) and (pointer: coarse), (max-width: 860px)" srcSet={segment.mobilePoster} /> ) : null} <img alt="" className="scroll-scrub__poster" decoding="async" fetchPriority={index === 0 ? "high" : "auto"} loading={index === 0 ? "eager" : "lazy"} src={segment.poster} /> </picture> </figure> ); })} </div>
<div aria-hidden="true" className="scroll-scrub__progress"> <span /> </div>
<nav aria-label="Scroll chapters" className="scroll-scrub__route"> {scenes.map((scene, index) => ( <button aria-current={activeSection === index ? "step" : undefined} className="scroll-scrub__route-button" key={scene.id} onClick={() => controllerRef.current?.jumpToSection(index)} type="button" > <span>{scene.label}</span> </button> ))} </nav> </div>
<div className="scroll-scrub__story"> {segments.map((segment) => { const bandStyle: CSSProperties = { minHeight: `${Math.max(segment.weight, 0.2) * 100}dvh`, };
if (segment.kind === "connector") { return ( <div aria-hidden="true" className="scroll-scrub__connector-band" data-scroll-scrub-band="" key={segment.key} style={bandStyle} /> ); }
const { scene } = segment; if (!scene) { return null; } const Heading = segment.sectionIndex === 0 ? "h1" : "h2";
return ( <article className="scroll-scrub__chapter" data-align={scene.align ?? "left"} data-scroll-scrub-band="" id={scene.id} key={segment.key} style={bandStyle} > <div className="scroll-scrub__chapter-pin"> <div className="scroll-scrub__copy"> {scene.kicker ? ( <p className="scroll-scrub__kicker">{scene.kicker}</p> ) : null} <Heading className="scroll-scrub__title"> {scene.title} </Heading> <p className="scroll-scrub__body">{scene.body}</p> {scene.tags?.length ? ( <ul className="scroll-scrub__tags"> {scene.tags.map((tag) => ( <li key={tag}>{tag}</li> ))} </ul> ) : null} {scene.actions ? ( <div className="scroll-scrub__actions">{scene.actions}</div> ) : null} </div> </div> </article> ); })} </div> </section> );}