From 3458ba463edb39e90258b057a0ec62fe2bcf28de Mon Sep 17 00:00:00 2001 From: Saurabh Date: Mon, 6 Jul 2026 19:19:41 +0530 Subject: [PATCH] fix: fit landing card previews to a real stage instead of a flat scale Landing cards flat-scaled every preview by 0.8, so anything with a bigger natural footprint (or a w-full layout with no definite width to resolve against) got clipped or collapsed into a narrow column. PreviewFit now renders each preview at a fixed 460px stage, measures its natural height, and scales the whole stage to fit the card (still capped at 0.8 for normal-size previews). Also: autoplay the shader-background preview instead of sitting frozen on one variant, and stop stretching short testimonial cards to match a taller neighbor's height. --- .../app/landing/landing-component-card.tsx | 38 +++++--- components/app/landing/preview-fit.tsx | 86 +++++++++++++++++++ components/app/landing/testimonial-card.tsx | 4 +- .../motion/shader-background.preview.tsx | 29 ++++++- 4 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 components/app/landing/preview-fit.tsx diff --git a/components/app/landing/landing-component-card.tsx b/components/app/landing/landing-component-card.tsx index 60b3da1..3d5eda9 100644 --- a/components/app/landing/landing-component-card.tsx +++ b/components/app/landing/landing-component-card.tsx @@ -1,6 +1,10 @@ +"use client"; + import Link from "next/link"; +import { useState } from "react"; import type { ComponentEntry } from "@/lib/registry"; import { NewBadge } from "@/components/app/docs/new-badge"; +import { PreviewFit } from "@/components/app/landing/preview-fit"; import { getPreview } from "@/components/previews"; export function LandingComponentCard({ @@ -11,9 +15,16 @@ export function LandingComponentCard({ category?: string; }) { const Preview = getPreview(category, component.slug); + const [hover, setHover] = useState(false); return ( -
+
setHover(true)} + onPointerLeave={() => setHover(false)} + onFocus={() => setHover(true)} + onBlur={() => setHover(false)} + > : null} -
-
- {Preview ? : null} -
- -
-
-

- {component.description} -

+ +
+

+ {component.description} +

+
-
-
+ } + > + {Preview ? : null} +
); diff --git a/components/app/landing/preview-fit.tsx b/components/app/landing/preview-fit.tsx new file mode 100644 index 0000000..c3bbfdc --- /dev/null +++ b/components/app/landing/preview-fit.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; + +// Matches the original flat scale so normal-size previews look unchanged; +// only previews bigger than the card shrink further to actually fit — no +// clipping, and the card itself stays a fixed, modest size. +const BASE_SCALE = 0.8; +const HOVER_SCALE = 0.84; +const MIN_SCALE = 0.22; + +// A real desktop width for the preview to render at before it gets scaled +// down — the same idea as screenshotting the full-size preview, then +// shrinking the image. Without this, a preview whose root is `w-full` has no +// definite width to resolve against inside a shrink-wrapped box, so the +// browser collapses it to the width of its narrowest fixed-size child and +// wraps everything else around that — the "mobile view" column look. +const STAGE_WIDTH = 460; + +/** + * Shrinks a preview to fit the card frame instead of clipping or collapsing. + * Renders the preview at a fixed desktop-like stage width (so `w-full` + * layouts inside it lay out the same as they would on their own detail page), + * measures the natural rendered height at that width, and scales the whole + * stage down to fit the card — capped so normal-size previews look the same + * as the original flat scale. + */ +export function PreviewFit({ + children, + hover, + overlay, +}: { + children: ReactNode; + hover: boolean; + overlay?: ReactNode; +}) { + const outerRef = useRef(null); + const stageRef = useRef(null); + const [fitScale, setFitScale] = useState(1); + + useLayoutEffect(() => { + const outer = outerRef.current; + const stage = stageRef.current; + if (!outer || !stage) return; + + const measure = () => { + // clientWidth/offsetHeight are the pre-transform layout size — unlike + // getBoundingClientRect, which reflects the scale() applied to this + // same element and would otherwise make each measurement read an + // already-shrunk box, compounding into the wrong scale every render. + const outerW = outer.clientWidth; + const outerH = outer.clientHeight; + const stageH = stage.offsetHeight; + if (!outerW || !outerH || !stageH) return; + const fit = Math.min( + (outerW * 0.94) / STAGE_WIDTH, + (outerH * 0.94) / stageH, + ); + setFitScale(Math.max(MIN_SCALE, fit)); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(outer); + ro.observe(stage); + return () => ro.disconnect(); + }, []); + + const scale = Math.min(BASE_SCALE, fitScale) * (hover ? HOVER_SCALE / BASE_SCALE : 1); + + return ( +
+
+ {children} +
+ {overlay} +
+ ); +} diff --git a/components/app/landing/testimonial-card.tsx b/components/app/landing/testimonial-card.tsx index 782ecfa..e4810f9 100644 --- a/components/app/landing/testimonial-card.tsx +++ b/components/app/landing/testimonial-card.tsx @@ -34,7 +34,9 @@ export function TestimonialCard({ rel="noreferrer noopener" className={cn( "group block rounded-3xl border border-border bg-card transition-colors duration-200 hover:border-border-strong", - compact ? "h-full w-[330px] whitespace-normal p-4" : "p-5", + // No forced height: a card sizes to its own tweet, so a short one-liner + // doesn't inherit a tall neighbor's height and sit with a dead gap. + compact ? "w-[330px] whitespace-normal p-4" : "p-5", )} >
diff --git a/components/previews/motion/shader-background.preview.tsx b/components/previews/motion/shader-background.preview.tsx index e30d6a6..02825ad 100644 --- a/components/previews/motion/shader-background.preview.tsx +++ b/components/previews/motion/shader-background.preview.tsx @@ -1,7 +1,8 @@ "use client"; +import { useReducedMotion } from "motion/react"; import type { ComponentType } from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ShaderBackground, type ShaderBackgroundProps, @@ -9,6 +10,8 @@ import { } from "@/components/motion/shader-background"; import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; +const AUTOPLAY_MS = 2400; + const VARIANTS: { id: string; value: ShaderBackgroundVariant; @@ -254,9 +257,31 @@ const Background = ShaderBackground as ComponentType< export function ShaderBackgroundPreview() { const [active, setActive] = useState(VARIANTS[0].id); const current = VARIANTS.find((v) => v.id === active) ?? VARIANTS[0]; + const reduce = useReducedMotion(); + const [paused, setPaused] = useState(false); + const activeRef = useRef(active); + activeRef.current = active; + + // Cycles through variants on its own so the preview reads as alive at a + // glance; pauses on hover/focus so picking a variant manually sticks. + useEffect(() => { + if (reduce || paused) return; + const id = setInterval(() => { + const i = VARIANTS.findIndex((v) => v.id === activeRef.current); + setActive(VARIANTS[(i + 1) % VARIANTS.length].id); + }, AUTOPLAY_MS); + return () => clearInterval(id); + }, [reduce, paused]); return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: pause-on-hover for the autoplay timer, not a real control +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)} + >