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.
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>
</>
);
}