Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions components/app/landing/landing-component-card.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -11,9 +15,16 @@ export function LandingComponentCard({
category?: string;
}) {
const Preview = getPreview(category, component.slug);
const [hover, setHover] = useState(false);

return (
<article className="group/card relative h-64">
<article
className="group/card relative h-72"
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onFocus={() => setHover(true)}
onBlur={() => setHover(false)}
>
<Link
href={`/components/${category}/${component.slug}`}
aria-label={`View ${component.name}`}
Expand All @@ -27,19 +38,20 @@ export function LandingComponentCard({
{component.badge === "new" ? <NewBadge /> : null}
</div>

<div className="relative mx-2 mb-2 flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-3xl bg-background px-5 py-5 contain-[paint]">
<div className="pointer-events-none flex w-full max-w-full origin-center scale-80 items-center justify-center overflow-hidden transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] contain-[paint] group-hover/card:scale-[0.84] group-focus-within/card:scale-[0.84] [&_*]:!cursor-default">
{Preview ? <Preview /> : null}
</div>

<div className="pointer-events-none absolute inset-0 rounded-3xl bg-transparent backdrop-blur-0 transition-[background-color,backdrop-filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/card:bg-card/55 group-hover/card:backdrop-blur-md group-focus-within/card:bg-card/55 group-focus-within/card:backdrop-blur-md">
<div className="absolute inset-x-0 bottom-0 translate-y-full px-4 py-3 transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/card:translate-y-0 group-focus-within/card:translate-y-0">
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{component.description}
</p>
<PreviewFit
hover={hover}
overlay={
<div className="pointer-events-none absolute inset-0 rounded-3xl bg-transparent backdrop-blur-0 transition-[background-color,backdrop-filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/card:bg-card/55 group-hover/card:backdrop-blur-md group-focus-within/card:bg-card/55 group-focus-within/card:backdrop-blur-md">
<div className="absolute inset-x-0 bottom-0 translate-y-full px-4 py-3 transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/card:translate-y-0 group-focus-within/card:translate-y-0">
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{component.description}
</p>
</div>
</div>
</div>
</div>
}
>
{Preview ? <Preview /> : null}
</PreviewFit>
</div>
</article>
);
Expand Down
86 changes: 86 additions & 0 deletions components/app/landing/preview-fit.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(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 (
<div
ref={outerRef}
className="relative mx-2 mb-2 flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-3xl bg-background p-3 contain-[paint]"
>
<div
ref={stageRef}
style={{ width: STAGE_WIDTH, transform: `scale(${scale})` }}
className="pointer-events-none flex origin-center shrink-0 items-center justify-center transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] contain-[paint] [&_*]:!cursor-default"
>
{children}
</div>
{overlay}
</div>
);
}
4 changes: 3 additions & 1 deletion components/app/landing/testimonial-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
>
<div className="flex items-center gap-3">
Expand Down
29 changes: 27 additions & 2 deletions components/previews/motion/shader-background.preview.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"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,
type ShaderBackgroundVariant,
} from "@/components/motion/shader-background";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";

const AUTOPLAY_MS = 2400;

const VARIANTS: {
id: string;
value: ShaderBackgroundVariant;
Expand Down Expand Up @@ -254,9 +257,31 @@ const Background = ShaderBackground as ComponentType<
export function ShaderBackgroundPreview() {
const [active, setActive] = useState<string>(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 (
<div className="flex w-full max-w-2xl flex-col gap-5 p-6">
// biome-ignore lint/a11y/noStaticElementInteractions: pause-on-hover for the autoplay timer, not a real control
<div
className="flex w-full max-w-2xl flex-col gap-5 p-6"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className="relative h-80 w-full overflow-hidden rounded-2xl border border-border">
<Background
key={current.id}
Expand Down
Loading