Feature Morph
Installation
npx shadcn@latest add https://uibrik.com/r/feature-morph.jsonInstall the packages
npm install @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/feature-morph/feature-morph.tsx"use client"; import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import useMeasure from "react-use-measure"; import { IconArrowRight, IconBell, IconBellFilled, IconBolt, IconBoltFilled, IconChartPie, IconChartPieFilled, IconLock, IconLockFilled, IconMessageCircle, IconMessageCircleFilled, IconPuzzle, IconPuzzleFilled, IconSparkles, IconSparklesFilled, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Duotone } from "../_cards/duotone"; // Feature morph. A row of icon tiles with one feature open in the middle as a // card: its icon, a title, a line and a call to action. Every few seconds the // card folds back into its icon, the row slides along, and the next icon grows // into the card, round and round. Pointing at or focusing the row holds it; // clicking a tile opens that one. // // The row is a window onto an endless strip: each slot is keyed by its place on // the strip, not by which feature it shows, so stepping on lets the tile at // one end shrink away while a new one grows in at the other. Widths and heights // animate as real layout rather than transforms, so text never stretches and // the open card stays centred while everything around it slides. Every card is // sized to the tallest feature's text, measured once, so nothing shifts as the // features change. export { Duotone }; export type Feature = { title: string; description: string; /** Shown in the tile, and in the corner of the card. A <Duotone /> fits best. */ icon: ReactNode; /** Tint for the icon while its card is open. */ color?: string; /** Link at the foot of the card. */ cta?: { label: string; href?: string; onClick?: () => void }; }; const D = (icon: Parameters<typeof Duotone>[0]["icon"], fill: Parameters<typeof Duotone>[0]["fill"]) => ( <Duotone icon={icon} fill={fill} size={24} /> ); const FEATURES: Feature[] = [ { title: "Analytics", description: "See where time goes, by team and by project, without building a single report.", icon: D(IconChartPie, IconChartPieFilled), color: "#2563eb", cta: { label: "Explore analytics" }, }, { title: "Automations", description: "Turn the busywork into rules that run themselves, on every change.", icon: D(IconBolt, IconBoltFilled), color: "#d97706", cta: { label: "Build a rule" }, }, { title: "Security", description: "SSO, audit logs and fine-grained roles, on by default for every workspace.", icon: D(IconLock, IconLockFilled), color: "#059669", cta: { label: "Read the overview" }, }, { title: "Integrations", description: "Connect the tools your team already lives in, in a couple of clicks.", icon: D(IconPuzzle, IconPuzzleFilled), color: "#7c3aed", cta: { label: "Browse apps" }, }, { title: "Comments", description: "Discuss work right where it happens, with threads that resolve themselves.", icon: D(IconMessageCircle, IconMessageCircleFilled), color: "#0891b2", cta: { label: "See how it works" }, }, { title: "Notifications", description: "Hear about what needs you, and nothing else, wherever you work.", icon: D(IconBell, IconBellFilled), color: "#e11d48", cta: { label: "Set preferences" }, }, { title: "AI assist", description: "Draft, summarise and triage in one keystroke, with your context built in.", icon: D(IconSparkles, IconSparklesFilled), color: "#db2777", cta: { label: "Try it free" }, }, ]; export type FeatureMorphProps = { features?: Feature[]; /** Items in the row, the open card included. Odd, so the card sits in the middle; fewer show if they don't fit. */ visible?: number; /** Turn on its own. Holds while pointed at or focused, and with reduced motion. */ autoplay?: boolean; /** How long each feature stays open while playing, ms. */ duration?: number; /** Names the row for screen readers. */ label?: string; onChange?: (index: number) => void; className?: string; }; const TILE = 56; // closed tile, px const GAP = 12; // between items, px const CHIP = 44; // icon chip in an open card, px const PAD = 20; // card padding, px // One curve for everything that moves or resizes, so the card, the chip, the // tiles and the slots all start and land together: ease-in-out, since every // part is moving from one place on screen to another. const EASE = [0.65, 0, 0.35, 1] as const; const MORPH = { duration: 0.75, ease: EASE } as const; const INSTANT = { duration: 0 } as const; // The words leave quickly before the card shrinks, and arrive once it's mostly open. const TEXT_IN = { duration: 0.4, delay: 0.4, ease: [0.23, 1, 0.32, 1] } as const; const TEXT_OUT = { duration: 0.18, ease: [0.4, 0, 1, 1] } as const; const mod = (a: number, n: number) => ((a % n) + n) % n; export function FeatureMorph({ features = FEATURES, visible = 7, autoplay = true, duration = 3200, label = "Features", onChange, className, }: FeatureMorphProps) { const reduce = useReducedMotion(); const [ref, { width }] = useMeasure(); // Every card is as tall as the tallest feature's words, so the row never changes height between features. const [measure, { height: textHeight }] = useMeasure(); const cardHeight = PAD + CHIP + 16 + textHeight + PAD; const n = features.length; // Place on the endless strip; the open feature is step mod n. const [step, setStep] = useState(0); const [held, setHeld] = useState(false); const playing = autoplay && !held && !reduce && n > 1; // As many tiles as fit beside the card, kept odd so the card stays centred. // The card takes about a third of the row, within a comfortable reading width. On a phone it gives // way so two tiles still fit beside it. const slot = TILE + GAP; const cardWidth = !width ? 300 : width >= 260 + GAP + slot * 2 ? Math.round(Math.min(320, Math.max(260, width * 0.36))) : Math.max(180, width - GAP - slot * 2); const fits = width ? 1 + Math.floor((width - cardWidth - GAP) / slot) : visible; let count = Math.max(1, Math.min(visible, n, fits)); if (count % 2 === 0) count -= 1; const side = (count - 1) / 2; const slots = Array.from({ length: count }, (_, i) => step - side + i); const go = (by: number) => { if (!by) return; setStep((s) => s + by); onChange?.(mod(step + by, n)); }; // One timer per feature, restarted whenever it changes or the row is let go. const goRef = useRef(go); useEffect(() => { goRef.current = go; }); useEffect(() => { if (!playing) return; const t = setTimeout(() => goRef.current(1), duration); return () => clearTimeout(t); }, [playing, step, duration]); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight") go(1); else if (e.key === "ArrowLeft") go(-1); else return; e.preventDefault(); }; const open = features[mod(step, n)]; const edge = "linear-gradient(to right, transparent, #000 8%, #000 92%, transparent)"; return ( <div ref={ref} role="group" aria-roledescription="carousel" aria-label={label} className={cn("relative w-full", className)} onPointerEnter={() => setHeld(true)} onPointerLeave={() => setHeld(false)} onFocus={() => setHeld(true)} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setHeld(false)} onKeyDown={onKeyDown} > {/* Every feature's words, unseen, stacked in one grid cell: its height is the tallest of them. */} <div ref={measure} aria-hidden className="pointer-events-none invisible absolute top-0 left-0 grid" style={{ width: cardWidth - PAD * 2 }}> {features.map((f, i) => ( <div key={i} className="col-start-1 row-start-1"> <CardText feature={f} tabbable={false} /> </div> ))} </div> {/* Soft edges, so tiles grow in and shrink away at the ends instead of popping. The height is held for the card. */} <div className="flex items-center justify-center py-6" style={{ maskImage: edge, WebkitMaskImage: edge, minHeight: textHeight ? cardHeight + 48 : undefined }} > <AnimatePresence initial={false}> {slots.map((v) => ( <Item key={v} feature={features[mod(v, n)]} open={v === step} cardWidth={cardWidth} cardHeight={cardHeight} reduce={!!reduce} onOpen={() => go(v - step)} /> ))} </AnimatePresence> </div> {/* Quiet while playing, so it doesn't talk over everything every few seconds. */} <p className="sr-only" aria-live={playing ? "off" : "polite"}> {open?.title}: {open?.description} </p> </div> ); } function Item({ feature, open, cardWidth, cardHeight, reduce, onOpen, }: { feature: Feature; open: boolean; cardWidth: number; cardHeight: number; reduce: boolean; onOpen: () => void; }) { const w = open ? cardWidth : TILE; const h = open ? Math.max(cardHeight, TILE) : TILE; const morph = reduce ? INSTANT : MORPH; const tint = { "--tint": feature.color ?? "currentColor" } as CSSProperties; return ( // The slot: its width includes the gap, so a tile shrinking away takes its spacing with it. <motion.div className="flex shrink-0 justify-center" initial={{ width: 0, opacity: 0, scale: 0.6 }} animate={{ width: w + GAP, opacity: 1, scale: 1 }} exit={{ width: 0, opacity: 0, scale: 0.6 }} transition={morph} > {/* One surface for both states: the tile's grey underneath, the card's face crossfading over it, both filling exactly the same shape, so a card closing into a tile never shows two outlines. */} <motion.div className="relative shrink-0 overflow-hidden bg-muted dark:bg-muted/70" style={tint} initial={false} animate={{ width: w, height: h, borderRadius: open ? 24 : 18 }} transition={morph} > <motion.div aria-hidden className="absolute inset-0 bg-background dark:bg-muted" initial={false} animate={{ opacity: open ? 1 : 0 }} transition={morph} /> {/* The icon: the whole tile while closed, the card's corner chip while open. */} <motion.button type="button" tabIndex={open ? -1 : 0} aria-hidden={open} aria-label={open ? undefined : `Show ${feature.title}`} onClick={open ? undefined : onOpen} className={cn( "absolute z-[1] grid place-items-center outline-none transition-colors duration-[750ms] ease-[cubic-bezier(0.65,0,0.35,1)]", "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500", open ? "cursor-default text-(--tint)" : "cursor-pointer text-muted-foreground hover:text-foreground", )} initial={false} animate={ open ? { left: PAD, top: PAD, width: CHIP, height: CHIP, borderRadius: 14 } : { left: 0, top: 0, width: TILE, height: TILE, borderRadius: 18 } } transition={morph} > {/* The chip's tint: gone as it grows to fill the tile, so the tile is just the surface's grey. */} <motion.span aria-hidden className="absolute inset-0 rounded-[inherit] bg-[color-mix(in_oklab,var(--tint)_12%,transparent)]" initial={false} animate={{ opacity: open ? 1 : 0 }} transition={morph} /> <span className="relative">{feature.icon}</span> </motion.button> {/* The words: in once the card has mostly opened, out at once when it closes. */} <motion.div aria-hidden={!open} className={cn("absolute", !open && "pointer-events-none")} style={{ left: PAD, top: PAD + CHIP + 16, width: cardWidth - PAD * 2 }} initial={false} animate={open ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: 4, filter: "blur(3px)" }} transition={reduce ? INSTANT : open ? TEXT_IN : TEXT_OUT} > <CardText feature={feature} tabbable={open} /> </motion.div> {/* One hairline on the surface's edge, above everything, the same in both states. */} <span aria-hidden className="pointer-events-none absolute inset-0 z-[2] rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.07)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]" /> </motion.div> </motion.div> ); } function CardText({ feature, tabbable }: { feature: Feature; tabbable: boolean }) { return ( <> <h3 className="text-base font-medium tracking-tight text-foreground">{feature.title}</h3> <p className="mt-1 text-pretty text-sm leading-6 text-muted-foreground">{feature.description}</p> {feature.cta && <Cta cta={feature.cta} tabbable={tabbable} />} </> ); } function Cta({ cta, tabbable }: { cta: NonNullable<Feature["cta"]>; tabbable: boolean }) { const className = cn( "group/cta mt-4 inline-flex items-center gap-1 rounded-md text-sm font-medium text-foreground outline-none", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500", ); const body = ( <> {cta.label} <IconArrowRight size={15} stroke={2} className="text-(--tint) transition-transform duration-200 group-hover/cta:translate-x-0.5" /> </> ); return cta.href ? ( <a href={cta.href} tabIndex={tabbable ? 0 : -1} className={className}> {body} </a> ) : ( <button type="button" tabIndex={tabbable ? 0 : -1} onClick={cta.onClick} className={className}> {body} </button> ); }
Adds the files to
components/uibrik/feature-morph and installs @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/feature-morph.Props
FeatureMorph- visible
- Type number
- 7
- Items in the row, the open card included. Fewer show when they don't fit, or when there are fewer features.
- autoplay
- Type boolean
- true
- Turn on its own. Holds while pointed at or focused, and with reduced motion.
- duration
- Type number
- 3200
- How long each feature stays open while playing.
- features
- Type { title: string; description: string; icon: ReactNode; color?: string; cta?: { label: string; href?: string; onClick?: () => void } }[]
- 7 sample features
- Features in order. <Duotone icon={…} fill={…} /> pairs a Tabler outline icon with its filled twin; color tints it while open.
- label
- Type string
- "Features"
- Names the row for screen readers.
- onChange
- Type (index: number) => void
- –
- Called with the newly open feature.
- className
- Type string
- –
- Classes for the wrapper.