Deft
Ask AI

Search

Search skills, authors and topics

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-websites

Scroll 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>  );}