Components

Expandable Features

A row of media panels where the open one grows and its neighbours give up their width, advancing on a dwell timer that freezes rather than restarts on hover.

Built for Wholana

Research your niche with a feed of the outliers actually working right now, filtered down to the subjects and formats you post in.

Decode any video

Write the next one

The dwell hook was restructured on the way over. The original wrote to a ref during render and reset progress in an effect, both of which this project's lint rules reject, so the reading now carries the key it was measured under and a panel change zeroes the rail by derivation instead of a second render. The behaviour is the same. The section heading it shipped under is a prop with a default, and this page passes its own instead: at 60px it was the largest type on the site and the panel row would have read as a caption to it.

Features

  • Dwell progress is measured against the wall clock and freezes when the pointer enters the section, so hovering holds the panel you are reading instead of restarting it.
  • The section only ticks while it is meaningfully in view, so an off-screen carousel is not burning frames.
  • Every panel carries the hairline track and only the active one fills, so the row reads as one ruled baseline rather than a progress bar that appears and disappears.
  • Panel media crops from the centre as a panel collapses while absolutely positioned children hold their size, which is what keeps the floating card readable at every width.

Usage

import { ExpandableFeatures } from "@/components/showcase/expandable-features/expandable-features";

<ExpandableFeatures />

Source

The files this component is made of, as they run above. Copy them in, or take the whole page as markdown from the toolbar.

expandable-features.tsx

"use client";

import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { SHOWCASE_HEADING, SHOWCASE_ITEMS } from "./showcase-items";

const DWELL_MS = 6000;
/* The site's one curve, --ease-out-quint in globals.css. Resizing is this
   panel's whole job, so it has to be on the same clock as everything else. */
const EASE_SMOOTH_OUT = [0.22, 1, 0.36, 1] as const;
const RESIZE_SECONDS = 0.7;

export type ExpandableFeature = {
  id: string;
  /** Bold lead-in, shown in every state. */
  title: string;
  /** Trails the title, revealed only while the panel is open. */
  description: string;
  /**
   * Anything paintable: an <Image>, a mock UI, a gradient. It fills the panel
   * and is cropped from the centre as the panel collapses; nested <img> gets
   * object-cover for free. Place fixed-size overlays absolutely so they keep
   * their scale through the resize.
   */
  media: ReactNode;
};

type ExpandableFeaturesProps = {
  heading?: ReactNode;
  items?: ExpandableFeature[];
  /** Milliseconds an open panel holds before auto-advancing. */
  dwellMs?: number;
  className?: string;
};

/**
 * Wall-clock progress from 0 to 1 that stops accumulating while `running` is
 * false, so hovering the section freezes the rail where it stands instead of
 * restarting it. Resets whenever `key` changes.
 */
function useDwellProgress(
  key: number,
  running: boolean,
  dwellMs: number,
  onComplete: () => void,
) {
  /* The reading carries the key it was measured under, so a panel change makes
     the old one stale by construction and the rail falls back to zero in the
     same render that switched panels. The original reset this with a setState
     in an effect, which is a whole second render pass whose only job is to undo
     the first, and a frame of the old panel's rail on the new panel. */
  const [tick, setTick] = useState({ key, progress: 0 });
  const elapsedRef = useRef(0);
  const completeRef = useRef(onComplete);

  /* Latest-callback ref, written after paint rather than during render, so the
     render stays pure. The loop only ever reads it from inside a frame, which
     is always after this has run. */
  useEffect(() => {
    completeRef.current = onComplete;
  }, [onComplete]);

  /* `key` is the reset signal: a panel change is exactly what should rewind
     elapsed time. Kept separate from the loop below because the loop also
     restarts whenever `running` flips, and a pause must freeze the rail where
     it stands rather than rewind it. */
  useEffect(() => {
    elapsedRef.current = 0;
  }, [key]);

  useEffect(() => {
    if (!running) return;
    let raf = 0;
    let last = performance.now();
    const step = (now: number) => {
      elapsedRef.current += now - last;
      last = now;
      const next = Math.min(1, elapsedRef.current / dwellMs);
      setTick({ key, progress: next });
      if (next >= 1) {
        completeRef.current();
        return;
      }
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [key, running, dwellMs]);

  return tick.key === key ? tick.progress : 0;
}

/**
 * A row of panels where the open one grows and the rest give up their width.
 * The section advances itself on a dwell timer, holds while the pointer is
 * over it, and can be driven by hover, click, focus or the arrow keys.
 *
 * Props are all optional so it renders standalone; the defaults are the real
 * Wholana product loop panels, frozen in showcase-items.tsx.
 */
export function ExpandableFeatures({
  heading = SHOWCASE_HEADING,
  items = SHOWCASE_ITEMS,
  dwellMs = DWELL_MS,
  className,
}: ExpandableFeaturesProps = {}) {
  const [active, setActive] = useState(0);
  const [paused, setPaused] = useState(false);
  const sectionRef = useRef<HTMLElement | null>(null);
  const inView = useInView(sectionRef, { amount: 0.4 });
  const reduceMotion = useReducedMotion();
  const groupId = useId();

  const advance = useCallback(() => {
    setActive((i) => (i + 1) % items.length);
  }, [items.length]);

  const running = inView && !paused && items.length > 1;
  const progress = useDwellProgress(active, running, dwellMs, advance);

  // Hovering anywhere in the section holds the current panel. Bound imperatively
  // so the <section> stays a non-interactive landmark.
  useEffect(() => {
    const node = sectionRef.current;
    if (!node) return;
    const hold = () => setPaused(true);
    const release = () => setPaused(false);
    node.addEventListener("pointerenter", hold);
    node.addEventListener("pointerleave", release);
    return () => {
      node.removeEventListener("pointerenter", hold);
      node.removeEventListener("pointerleave", release);
    };
  }, []);

  const onKeyDown = (event: KeyboardEvent, index: number) => {
    const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
    const back = event.key === "ArrowLeft" || event.key === "ArrowUp";
    if (!forward && !back) return;
    event.preventDefault();
    setActive((index + (forward ? 1 : -1) + items.length) % items.length);
  };

  return (
    <section ref={sectionRef} className={className}>
      {heading}

      {/* The gap belongs to the heading, not to the row: `heading` is optional,
          and a row that reserves 40px above itself for a heading that was not
          passed opens the section with dead space. */}
      <div
        className={`flex flex-col gap-3 md:flex-row md:items-stretch ${heading ? "mt-10" : ""}`}
      >
        {items.map((item, index) => {
          const isActive = index === active;
          return (
            <motion.div
              key={item.id}
              initial={false}
              animate={{ flexGrow: isActive ? 2 : 1 }}
              transition={{
                duration: reduceMotion ? 0 : RESIZE_SECONDS,
                ease: EASE_SMOOTH_OUT,
              }}
              className="flex min-w-0 basis-0 flex-col"
            >
              <button
                type="button"
                aria-expanded={isActive}
                aria-controls={`${groupId}-${item.id}`}
                onClick={() => setActive(index)}
                onFocus={() => setActive(index)}
                onMouseEnter={() => setActive(index)}
                onKeyDown={(event) => onKeyDown(event, index)}
                className="group relative block h-[240px] w-full cursor-pointer overflow-hidden rounded-xl bg-foreground/5 text-left focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 md:h-[300px] lg:h-[340px]"
              >
                {/* The media fills the panel; a collapsing panel crops it from
                    both edges. Anything absolutely placed inside (a floating
                    card, a callout) holds its size through the resize instead
                    of squashing with the panel. */}
                <div className="absolute inset-0">{item.media}</div>
                <motion.div
                  aria-hidden
                  className="absolute inset-0 bg-background"
                  initial={false}
                  animate={{ opacity: isActive ? 0 : 0.14 }}
                  transition={{
                    duration: reduceMotion ? 0 : 0.4,
                    ease: "easeOut",
                  }}
                />
              </button>

              {/* Every panel carries the hairline track so the row reads as one
                  ruled baseline; only the active panel draws a fill on top, and
                  reaching 100% is what hands the section over to the next one. */}
              <div className="mt-[11px] h-px w-full overflow-hidden bg-foreground/10">
                <div
                  className="h-px origin-left bg-foreground/80"
                  style={{
                    transform: `scaleX(${isActive && !reduceMotion ? progress : 0})`,
                    opacity: isActive && !reduceMotion ? 1 : 0,
                  }}
                />
              </div>

              {/* The scale's rungs rather than the landing's raw values, for
                  the same reason the ink below is `foreground-muted`. The
                  original was `text-[13px] leading-[1.5] md:text-sm`, which
                  had three problems: 14px is not a step on this scale, the
                  hand-typed leading was the 13px rung's own 1.5 retyped, and
                  it was not breakpoint-scoped, so above `md` it held 1.5
                  against a size that wanted 1.6. Each rung now carries its
                  own leading and tracking. */}
              <p id={`${groupId}-${item.id}`} className="text-meta md:text-body-sm mt-5">
                {/* `font-medium`. 600 appears nowhere in this site's authored
                    code; the whole thing is 400 and 500. */}
                <span className="text-foreground font-medium">{item.title}</span>
                <AnimatePresence initial={false}>
                  {isActive && (
                    /* `text-foreground-muted`, not the landing's `text-muted`:
                       in this system `muted` names a surface, and the ink
                       ramp is `foreground-*`. */
                    <motion.span
                      className="inline text-foreground-muted"
                      initial={{ opacity: 0, filter: "blur(4px)" }}
                      animate={{ opacity: 1, filter: "blur(0px)" }}
                      exit={{
                        opacity: 0,
                        filter: "blur(4px)",
                        transition: {
                          duration: reduceMotion ? 0 : 0.2,
                          ease: "easeOut",
                          delay: 0,
                        },
                      }}
                      transition={{
                        duration: reduceMotion ? 0 : 0.45,
                        ease: "easeOut",
                        // Starts while the panel is still growing, so the copy
                        // resolves into place rather than popping in after.
                        delay: reduceMotion ? 0 : 0.1,
                      }}
                    >
                      {" "}
                      {item.description}
                    </motion.span>
                  )}
                </AnimatePresence>
              </p>
            </motion.div>
          );
        })}
      </div>
    </section>
  );
}

showcase-items.tsx

"use client";

import exploreFeed from "./app-explore-feed.png";
import scriptEditor from "./app-script-editor.png";
import videoDetail from "./app-video-detail.png";
import { PanelMedia } from "./panel-media";
import type { ExpandableFeature } from "./expandable-features";

/**
 * The three panels the component ships with, frozen here so it renders with no
 * props and no data access. These are the real Wholana landing panels: the
 * product loop, research to decode to script.
 *
 * The screenshots are imported as static assets rather than pointed at
 * /public, so the whole directory moves as one unit.
 */
export const SHOWCASE_ITEMS: ExpandableFeature[] = [
  {
    id: "research",
    title: "Research your niche",
    description:
      "with a feed of the outliers actually working right now, filtered down to the subjects and formats you post in.",
    media: (
      <PanelMedia
        backdrop="radial-gradient(90% 60% at 18% 6%, #d3dde3 0%, transparent 60%), radial-gradient(70% 45% at 78% 100%, #c8bda9 0%, transparent 62%), radial-gradient(120% 40% at 40% 96%, #ddd5c6 0%, transparent 70%), radial-gradient(80% 60% at 60% 30%, #f3f1eb 0%, transparent 65%), #e6e6e0"
        src={exploreFeed}
        alt="Trending subjects in the Wholana explore feed"
        cardWidth={330}
        cardHeight={191}
        imageWidth={713}
        imageLeft={-366}
        imageTop={-180}
        offsetX={56}
        offsetY={-8}
      />
    ),
  },
  {
    id: "decode",
    title: "Decode any video",
    description:
      "into its hook, structure, shareability, and format, so you can see why it worked instead of guessing.",
    media: (
      <PanelMedia
        backdrop="radial-gradient(90% 55% at 82% 4%, #e9dfc9 0%, transparent 62%), radial-gradient(75% 50% at 12% 100%, #cfc4b2 0%, transparent 60%), radial-gradient(110% 35% at 55% 98%, #ded2bf 0%, transparent 72%), radial-gradient(80% 60% at 42% 38%, #f4f0e5 0%, transparent 66%), #e8e3d7"
        src={videoDetail}
        alt="A decoded video's craft breakdown, beat by beat"
        cardWidth={330}
        cardHeight={212}
        imageWidth={1032}
        // Nudged up-left from (-449, -325) so the crop keeps a teammate's
        // pointer whole. At the old offsets the arrow fell outside the window
        // and only its name tag survived, which reads as a stray blue chip
        // rather than as somebody reading the beat.
        imageLeft={-434}
        imageTop={-308}
        offsetX={-52}
        offsetY={4}
      />
    ),
  },
  {
    id: "script",
    title: "Write the next one",
    description:
      "straight from a saved reference, with the beats and the voice you already know land for your audience.",
    media: (
      <PanelMedia
        backdrop="radial-gradient(85% 55% at 14% 8%, #e6e8de 0%, transparent 60%), radial-gradient(70% 45% at 88% 100%, #c3c6b4 0%, transparent 62%), radial-gradient(110% 38% at 45% 97%, #d7dac9 0%, transparent 70%), radial-gradient(80% 60% at 60% 32%, #f2f2ec 0%, transparent 65%), #e5e7de"
        src={scriptEditor}
        alt="A draft script in the Wholana script editor"
        cardWidth={340}
        cardHeight={170}
        imageWidth={723}
        imageLeft={-166}
        imageTop={-83}
        offsetX={40}
        offsetY={-6}
      />
    ),
  },
];

/** The section heading the panels shipped under. */
export const SHOWCASE_HEADING = (
  <>
    <p className="mb-4 font-medium text-[13px] text-foreground-faint tracking-wide md:mb-5">
      The loop
    </p>
    <h2 className="max-w-[1100px] text-[clamp(2rem,5vw,3.75rem)] text-foreground leading-[1.05] tracking-[-0.01em]">
      Research, decode, script.
    </h2>
  </>
);

panel-media.tsx

"use client";

import Image, { type StaticImageData } from "next/image";

type PanelMediaProps = {
  /** Layered CSS background for the painterly backdrop. */
  backdrop: string;
  src: StaticImageData;
  alt: string;
  /** Fixed floating-card crop window, in CSS px. */
  cardWidth: number;
  cardHeight: number;
  /** Rendered width of the full screenshot inside the crop window. */
  imageWidth: number;
  /** Negative offsets that slide the chosen region into the window. */
  imageLeft: number;
  imageTop: number;
  /** Card offset from the panel centre; the card keeps this anchor while the panel crops around it. */
  offsetX: number;
  offsetY: number;
};

/**
 * A quiet tonal backdrop with a fixed-size floating product card over it. The
 * card is centre-anchored with a pixel offset, so a collapsing panel crops the
 * scene from both edges while the card holds its size and position.
 *
 * Every number below is CSS px against `imageWidth`, not against the file's
 * intrinsic pixels, which is why the screenshots could be resampled on the way
 * into this directory without any of the crops moving.
 */
export function PanelMedia({
  backdrop,
  src,
  alt,
  cardWidth,
  cardHeight,
  imageWidth,
  imageLeft,
  imageTop,
  offsetX,
  offsetY,
}: PanelMediaProps) {
  return (
    <>
      <div
        aria-hidden
        className="absolute inset-0"
        style={{ background: backdrop }}
      />
      <div
        className="absolute top-1/2 left-1/2"
        style={{
          transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
        }}
      >
        <div className="rounded-2xl bg-white/70 p-1.5 shadow-[0_24px_50px_-18px_rgba(56,48,38,0.35)] backdrop-blur-[2px]">
          <div
            className="relative overflow-hidden rounded-[12px] bg-white"
            style={{ width: cardWidth, height: cardHeight }}
          >
            <Image
              src={src}
              alt={alt}
              sizes={`${imageWidth}px`}
              className="absolute max-w-none"
              style={{
                width: imageWidth,
                height: "auto",
                left: imageLeft,
                top: imageTop,
              }}
            />
          </div>
        </div>
      </div>
    </>
  );
}