"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { getImageProps } from "next/image"
import type React from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/src/utils"
import { useFeatureTourTuning } from "./tuning"
const EASE = [0.32, 0.72, 0, 1] as const
const CARD_WIDTH = 340
const PILL_HEIGHT = 48
const ITEMS = [
{
slug: "squircle",
title: "Squircle",
body: "The rounded rectangle iOS uses everywhere, which is not the one border-radius gives you. One SVG filter, no geometry.",
},
{
slug: "liquid-glass",
title: "Liquid Glass",
body: "Real refraction through real geometry in Three.js, not a backdrop blur with a displacement map faking the bend.",
},
{
slug: "dynamic-island",
title: "Dynamic Island",
body: "The morph between idle, compact and expanded, rebuilt with one layout-animated pill and two copies of the view.",
},
{
slug: "ios-menu",
title: "iOS Menu",
body: "The submenu that opens in place when you tap Open With in Files. No popover library, no second panel sliding in.",
},
{
slug: "fluid-input",
title: "Fluid Input",
body: "A search field that widens into a full input on a single spring. No CSS transitions, no timers, no measuring the DOM by hand.",
},
{
slug: "letter-cascade",
title: "Letter Cascade",
body: "A per-letter reveal built entirely in CSS, with one custom property staggering every letter's delay.",
},
]
type Item = (typeof ITEMS)[number]
export function FeatureTour() {
const [index, setIndex] = useState(0)
function select(next: number) {
if (next < 0 || next >= ITEMS.length) return
setIndex(next)
}
return (
<>
<div
className={"flex w-full flex-col-reverse items-center gap-8 md:flex-row md:gap-10"}
>
<div className={"flex w-full items-center gap-4 md:w-[400px] md:shrink-0"}>
<Arrows index={index} onSelect={select} />
<List index={index} onSelect={select} />
</div>
<Media item={ITEMS[index]} />
</div>
</>
)
}
interface NavigationProps {
index: number
onSelect: (index: number) => void
}
function Arrows({ index, onSelect }: NavigationProps) {
const buttons = [
{ label: "Previous", path: "M5 15l7-7 7 7", target: index - 1 },
{ label: "Next", path: "M5 9l7 7 7-7", target: index + 1 },
]
return (
<div className={"flex flex-col gap-3"}>
{buttons.map(({ label, path, target }) => (
<button
key={label}
type={"button"}
aria-label={label}
aria-disabled={target < 0 || target >= ITEMS.length}
onClick={() => onSelect(target)}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-full bg-neutral-200/80 text-neutral-900 transition-[opacity,scale] duration-150 ease-out before:absolute before:-inset-1 before:content-[''] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 not-aria-disabled:active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 motion-reduce:transition-none dark:bg-neutral-800/80 dark:text-white"
}
>
{
}
<svg
aria-hidden={"true"}
width={18}
height={18}
viewBox={"0 0 24 24"}
fill={"none"}
stroke={"currentColor"}
strokeWidth={3.5}
strokeLinecap={"round"}
strokeLinejoin={"round"}
>
<path d={path} />
</svg>
</button>
))}
</div>
)
}
function List({ index, onSelect }: NavigationProps) {
const [list, setList] = useState<HTMLUListElement | null>(null)
const [listWidth, setListWidth] = useState(CARD_WIDTH)
useLayoutEffect(() => {
if (!list) return
const observer = new ResizeObserver(() => setListWidth(list.offsetWidth))
observer.observe(list)
return () => observer.disconnect()
}, [list])
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key]
if (!step) return
event.preventDefault()
const buttons = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
]
const focused = buttons.indexOf(event.target as HTMLButtonElement)
const next = (focused === -1 ? index : focused) + step
if (next < 0 || next >= ITEMS.length) return
onSelect(next)
buttons[next].focus()
}
return (
<ul
ref={setList}
className={"flex min-w-0 flex-1 flex-col items-start gap-2"}
onKeyDown={onKeyDown}
>
{ITEMS.map((item, itemIndex) => (
<li key={item.slug} className={"max-w-full"}>
<TourItem
item={item}
open={itemIndex === index}
cardWidth={Math.min(CARD_WIDTH, listWidth)}
onSelect={() => onSelect(itemIndex)}
/>
</li>
))}
</ul>
)
}
interface TourItemProps {
item: Item
open: boolean
cardWidth: number
onSelect: () => void
}
function TourItem({ item, open, cardWidth, onSelect }: TourItemProps) {
const { morph, fadeOut, fadeIn } = useFeatureTourTuning()
const reduced = useReducedMotion()
const [content, setContent] = useState<HTMLElement | null>(null)
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
useLayoutEffect(() => {
if (!content) return
const measure = () => setSize({ width: content.offsetWidth, height: content.offsetHeight })
measure()
const observer = new ResizeObserver(measure)
observer.observe(content)
return () => observer.disconnect()
}, [content])
const settled = useRef(false)
useEffect(() => {
if (size) settled.current = true
}, [size])
const resize =
reduced || !settled.current ? { duration: 0 } : { type: "spring" as const, ...morph }
const enter = reduced ? { duration: 0 } : { ...fadeIn, ease: EASE }
const leave = reduced ? { duration: 0 } : { duration: fadeOut, ease: "linear" as const }
const keep = (element: HTMLElement | null) => {
if (element) setContent(element)
}
return (
<motion.button
type={"button"}
aria-expanded={open}
aria-disabled={open}
onClick={onSelect}
initial={false}
animate={size ?? { height: PILL_HEIGHT }}
transition={resize}
className={cn(
"relative block max-w-full cursor-pointer overflow-hidden rounded-[24px] bg-neutral-100 text-left text-neutral-900 outline-none focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 dark:bg-neutral-900 dark:text-white",
open
? "cursor-default"
: "transition-colors duration-150 ease-out hover:bg-neutral-200 motion-reduce:transition-none dark:hover:bg-neutral-800",
)}
>
<AnimatePresence initial={false}>
{open ? (
<motion.p
key={"card"}
ref={keep}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: enter }}
exit={{ opacity: 0, transition: leave }}
style={{ width: cardWidth }}
className={"absolute top-0 left-0 px-6 py-5 text-[15px] leading-6"}
>
<strong className={"font-semibold"}>{`${item.title}.`}</strong> {item.body}
</motion.p>
) : (
<motion.span
key={"pill"}
ref={keep}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: enter }}
exit={{ opacity: 0, transition: leave }}
className={
"absolute top-0 left-0 flex h-12 items-center gap-2.5 whitespace-nowrap pr-5 pl-3 font-medium text-[15px]"
}
>
<PlusMark />
{item.title}
</motion.span>
)}
</AnimatePresence>
</motion.button>
)
}
function PlusMark() {
return (
<svg
aria-hidden={"true"}
width={26}
height={26}
viewBox={"0 0 24 24"}
fill={"none"}
stroke={"currentColor"}
strokeLinecap={"round"}
className={"shrink-0"}
>
<circle cx={12} cy={12} r={11} strokeWidth={0.9} />
<path d={"M12 7.2v9.6M7.2 12h9.6"} strokeWidth={1.5} />
</svg>
)
}
function Media({ item }: { item: Item }) {
const { media } = useFeatureTourTuning()
const reduced = useReducedMotion()
const common = { alt: item.title, fill: true, sizes: "(min-width: 768px) 420px, 100vw" }
const {
props: { srcSet: dark },
} = getImageProps({ ...common, src: `/experiments/feature-tour/media/${item.slug}-dark.png` })
const { props: light } = getImageProps({
...common,
src: `/experiments/feature-tour/media/${item.slug}-light.png`,
loading: item.slug === ITEMS[0].slug ? "eager" : "lazy",
})
return (
<div className={"relative aspect-[4/3] w-full md:aspect-auto md:h-[420px] md:flex-1"}>
<AnimatePresence initial={false} mode={"wait"}>
<motion.picture
key={item.slug}
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduced ? { duration: 0 } : { duration: media, ease: EASE }}
>
<source media={"(prefers-color-scheme: dark)"} srcSet={dark} />
<img {...light} alt={item.title} draggable={false} className={"object-contain"} />
</motion.picture>
</AnimatePresence>
</div>
)
}