chore: lint using react-doctor, update translations, dynamic imports

This commit is contained in:
Amruth Pillai
2026-05-21 09:56:26 +02:00
parent 3596102c63
commit 39e88dd365
208 changed files with 5876 additions and 4778 deletions
+23 -23
View File
@@ -2,7 +2,7 @@ import type { IconProps } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, HeartIcon, RocketIcon, SparkleIcon, UsersIcon, WrenchIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
@@ -13,7 +13,7 @@ type FloatingIconProps = {
};
const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) => (
<motion.div
<m.div
className={cn("absolute text-primary/20", className)}
animate={{
y: [0, -12, 0],
@@ -28,11 +28,11 @@ const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) =
}}
>
<Icon size={32} weight="duotone" />
</motion.div>
</m.div>
);
const PulsingHeart = () => (
<motion.div
<m.div
className="relative inline-flex items-center justify-center"
animate={{
scale: [1, 1.15, 1],
@@ -44,7 +44,7 @@ const PulsingHeart = () => (
}}
>
<HeartIcon size={48} weight="fill" className="text-rose-500" />
<motion.div
<m.div
className="absolute inset-0 flex items-center justify-center"
animate={{
scale: [1, 1.8],
@@ -57,8 +57,8 @@ const PulsingHeart = () => (
}}
>
<HeartIcon size={48} weight="fill" className="text-rose-500" />
</motion.div>
</motion.div>
</m.div>
</m.div>
);
type SparkleEffectProps = {
@@ -66,7 +66,7 @@ type SparkleEffectProps = {
};
const SparkleEffect = ({ className }: SparkleEffectProps) => (
<motion.div
<m.div
className={cn("absolute", className)}
animate={{
scale: [0, 1, 0],
@@ -80,7 +80,7 @@ const SparkleEffect = ({ className }: SparkleEffectProps) => (
}}
>
<SparkleIcon size={16} weight="fill" className="text-amber-400" />
</motion.div>
</m.div>
);
type FeatureCardProps = {
@@ -91,7 +91,7 @@ type FeatureCardProps = {
};
const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps) => (
<motion.div
<m.div
className="group relative flex flex-col items-center gap-3 rounded-2xl border border-border/50 bg-card/50 p-6 text-center backdrop-blur-sm transition-colors hover:border-primary/30 hover:bg-card/80"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -99,17 +99,17 @@ const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps
transition={{ duration: 0.5, delay }}
whileHover={{ y: -4 }}
>
<motion.div
<m.div
aria-hidden="true"
className="flex size-12 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
whileHover={{ rotate: [0, -10, 10, 0] }}
transition={{ duration: 0.4 }}
>
<Icon size={24} weight="light" />
</motion.div>
</m.div>
<h3 className="font-semibold tracking-tight">{title}</h3>
<p className="text-muted-foreground leading-relaxed">{description}</p>
</motion.div>
</m.div>
);
export const DonationBanner = () => (
@@ -130,7 +130,7 @@ export const DonationBanner = () => (
<div className="container relative px-8">
{/* Header */}
<motion.div
<m.div
className="flex flex-col items-center text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -143,7 +143,7 @@ export const DonationBanner = () => (
<SparkleEffect className="-inset-s-3 bottom-0" />
</div>
<motion.h2
<m.h2
className="mb-6 font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -151,9 +151,9 @@ export const DonationBanner = () => (
transition={{ duration: 0.6, delay: 0.1 }}
>
<Trans>Support Reactive Resume</Trans>
</motion.h2>
</m.h2>
<motion.p
<m.p
className="max-w-3xl text-base text-muted-foreground leading-relaxed"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -164,8 +164,8 @@ export const DonationBanner = () => (
Reactive Resume is a free and open-source project, built with love and maintained by me and a community of
contributors. Your donations help keep the lights on and the code flowing.
</Trans>
</motion.p>
</motion.div>
</m.p>
</m.div>
{/* Feature cards */}
<div className="mx-auto my-12 grid max-w-5xl gap-8 sm:grid-cols-3">
@@ -190,7 +190,7 @@ export const DonationBanner = () => (
</div>
{/* CTA Buttons */}
<motion.div
<m.div
className="flex flex-col items-center justify-center gap-4 sm:flex-row"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -222,10 +222,10 @@ export const DonationBanner = () => (
</a>
}
/>
</motion.div>
</m.div>
{/* Footer note */}
<motion.p
<m.p
className="mt-8 text-center text-muted-foreground leading-relaxed"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
@@ -237,7 +237,7 @@ export const DonationBanner = () => (
<br />
Thank you for your support!
</Trans>
</motion.p>
</m.p>
</div>
</section>
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { motion } from "motion/react";
import { m } from "motion/react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { buttonVariants } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
@@ -68,7 +68,7 @@ export function FAQ() {
id="frequently-asked-questions"
className="flex flex-col gap-x-16 gap-y-6 p-4 md:p-8 lg:flex-row lg:gap-x-18 xl:py-16"
>
<motion.h2
<m.h2
className={cn(
"flex-1 font-semibold text-2xl tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
@@ -83,9 +83,9 @@ export function FAQ() {
<span>Asked</span>
<span>Questions</span>
</Trans>
</motion.h2>
</m.h2>
<motion.div
<m.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@@ -97,7 +97,7 @@ export function FAQ() {
<FAQItemComponent key={item.question} item={item} index={index} />
))}
</Accordion>
</motion.div>
</m.div>
</section>
);
}
@@ -109,7 +109,7 @@ type FAQItemComponentProps = {
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
return (
<motion.div
<m.div
className="will-change-[transform,opacity] last:border-b"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -120,6 +120,6 @@ function FAQItemComponent({ item, index }: FAQItemComponentProps) {
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
</AccordionItem>
</motion.div>
</m.div>
);
}
@@ -19,7 +19,7 @@ import {
ShieldCheckIcon,
TranslateIcon,
} from "@phosphor-icons/react";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useMemo } from "react";
import { cn } from "@reactive-resume/utils/style";
@@ -133,7 +133,7 @@ const getFeatures = (): Feature[] => [
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
return (
<motion.div
<m.div
className={cn(
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
@@ -162,7 +162,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
<h3 className="font-semibold text-base tracking-tight transition-colors group-hover:text-primary">{title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
</div>
</motion.div>
</m.div>
);
}
@@ -172,7 +172,7 @@ export function Features() {
return (
<section id="features">
{/* Header */}
<motion.div
<m.div
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -189,7 +189,7 @@ export function Features() {
powered by open source, and completely free forever.
</Trans>
</p>
</motion.div>
</m.div>
{/* Features Grid */}
<div className="grid grid-cols-1 xs:grid-cols-2 border-t xl:grid-cols-4">
@@ -2,7 +2,7 @@ import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useState } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
@@ -46,7 +46,7 @@ const socialLinks: SocialLink[] = [
export function Footer() {
return (
<motion.footer
<m.footer
id="footer"
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
initial={{ opacity: 0 }}
@@ -60,7 +60,7 @@ export function Footer() {
<BrandIcon variant="logo" className="size-10" />
<div className="space-y-2">
<h2 className="font-bold text-lg tracking-tight">Reactive Resume</h2>
<h2 className="font-semibold text-lg tracking-tight">Reactive Resume</h2>
<p className="max-w-xs text-muted-foreground text-sm leading-relaxed">
<Trans>
A free and open-source resume builder that simplifies the process of creating, updating, and sharing
@@ -103,7 +103,7 @@ export function Footer() {
<Copyright />
</div>
</div>
</motion.footer>
</m.footer>
);
}
@@ -140,7 +140,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
<Trans>(opens in new tab)</Trans>
</span>
<motion.div
<m.div
aria-hidden="true"
initial={{ width: 0, opacity: 0 }}
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { motion, useMotionValue, useSpring } from "motion/react";
import { m, useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
@@ -41,7 +41,7 @@ export function Header() {
}, [y]);
return (
<motion.header
<m.header
style={{ y: springY }}
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
initial={{ y: -100, opacity: 0 }}
@@ -80,6 +80,6 @@ export function Header() {
</div>
</div>
</nav>
</motion.header>
</m.header>
);
}
+17 -19
View File
@@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, BookIcon, SparkleIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { motion } from "motion/react";
import { m } from "motion/react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { CometCard } from "@/components/animation/comet-card";
@@ -16,7 +16,7 @@ export function Hero() {
>
<Spotlight />
<motion.div
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
@@ -28,8 +28,6 @@ export function Hero() {
muted
autoPlay
playsInline
// @ts-expect-error - typescript doesn't know about fetchPriority for video elements
fetchPriority="high"
src="/videos/timelapse.mp4"
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
className="pointer-events-none size-full rounded-md border object-cover"
@@ -40,11 +38,11 @@ export function Hero() {
className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-40% via-transparent to-background"
/>
</CometCard>
</motion.div>
</m.div>
<div className="relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 xs:px-0 text-center">
{/* Badge */}
<motion.a
<m.a
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -59,10 +57,10 @@ export function Hero() {
<SparkleIcon aria-hidden="true" className="size-3.5" weight="fill" />
<Trans>What's new in the latest version?</Trans>
</Badge>
</motion.a>
</m.a>
{/* Headline */}
<motion.div
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -70,14 +68,14 @@ export function Hero() {
>
<Trans>
<p className="font-medium text-muted-foreground tracking-tight md:text-lg">Finally,</p>
<h1 className="mt-1 font-bold text-4xl tracking-tight md:text-5xl lg:text-6xl">
<h1 className="mt-1 font-semibold text-4xl tracking-tight md:text-5xl lg:text-6xl">
A free and open-source resume builder
</h1>
</Trans>
</motion.div>
</m.div>
{/* Description */}
<motion.p
<m.p
className="max-w-xl text-base text-muted-foreground leading-relaxed will-change-[transform,opacity] md:text-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -87,10 +85,10 @@ export function Hero() {
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
and sharing your resume.
</Trans>
</motion.p>
</m.p>
{/* CTA Buttons */}
<motion.div
<m.div
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -128,11 +126,11 @@ export function Hero() {
</a>
}
/>
</motion.div>
</m.div>
</div>
{/* Scroll indicator - decorative */}
<motion.div
<m.div
aria-hidden="true"
role="presentation"
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
@@ -140,14 +138,14 @@ export function Hero() {
animate={{ opacity: 1 }}
transition={{ delay: 1.25, duration: 0.7 }}
>
<motion.div
<m.div
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
animate={{ y: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
>
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
</motion.div>
</motion.div>
<m.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
</m.div>
</m.div>
</section>
);
}
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { motion } from "motion/react";
import { m } from "motion/react";
import { TextMaskEffect } from "@/components/animation/text-mask";
export function Prefooter() {
@@ -14,14 +14,14 @@ export function Prefooter() {
<div className="relative space-y-8">
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
<motion.div
<m.div
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
>
<h2 className="font-bold text-2xl tracking-tight md:text-4xl">
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl">
<Trans>By the community, for the community.</Trans>
</h2>
@@ -33,7 +33,7 @@ export function Prefooter() {
accessible to a broader audience, and the people who've donated to support its continued development.
</Trans>
</p>
</motion.div>
</m.div>
</div>
</section>
);
@@ -2,7 +2,7 @@ import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { FileTextIcon, UsersIcon } from "@phosphor-icons/react";
import { useQueries } from "@tanstack/react-query";
import { motion } from "motion/react";
import { m } from "motion/react";
import { CountUp } from "@/components/animation/count-up";
import { orpc } from "@/libs/orpc/client";
@@ -37,7 +37,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
const Icon = statistic.icon;
return (
<motion.div
<m.div
className="group relative flex flex-col items-center justify-center gap-y-4 border-r border-b p-8 transition-colors last:border-e-0 hover:bg-secondary/30 sm:border-b-0 xl:py-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -46,7 +46,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
>
{/* Background decoration */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<motion.div
<m.div
className="absolute inset-s-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-primary/2"
initial={{ scale: 0.8, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
@@ -54,18 +54,18 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
transition={{ duration: 0.8, delay: index * 0.1 + 0.2 }}
>
<Icon size={180} weight="thin" />
</motion.div>
</m.div>
</div>
{/* Icon */}
<motion.div
<m.div
aria-hidden="true"
className="relative rounded-full bg-primary/10 p-3 text-primary"
whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
>
<Icon size={24} weight="thin" />
</motion.div>
</m.div>
{/* Value */}
<CountUp
@@ -78,7 +78,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
{/* Label */}
<p className="relative font-medium text-base text-muted-foreground tracking-tight">{statistic.label}</p>
</motion.div>
</m.div>
);
}
@@ -1,6 +1,6 @@
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
import { Trans } from "@lingui/react/macro";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useMemo } from "react";
import { templates } from "@/dialogs/resume/template/data";
@@ -8,9 +8,14 @@ type TemplateItemProps = {
metadata: TemplateMetadata;
};
type TemplateMarqueeItem = {
id: string;
metadata: TemplateMetadata;
};
function TemplateItem({ metadata }: TemplateItemProps) {
return (
<motion.div
<m.div
className="group relative shrink-0 will-change-transform"
initial={{ scale: 1, zIndex: 10 }}
whileHover={{ scale: 1.06, zIndex: 20 }}
@@ -31,22 +36,21 @@ function TemplateItem({ metadata }: TemplateItemProps) {
{/* Shine effect on hover */}
<div className="pointer-events-none absolute inset-0 -translate-x-full rotate-12 bg-linear-to-r from-transparent via-white/10 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</div>
</motion.div>
</m.div>
);
}
type MarqueeRowProps = {
templates: Array<[string, TemplateMetadata]>;
rowId: string;
templates: TemplateMarqueeItem[];
direction: "left" | "right";
duration?: number;
};
function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowProps) {
function MarqueeRow({ templates, direction, duration = 40 }: MarqueeRowProps) {
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
return (
<motion.div
<m.div
className="flex gap-x-4 will-change-transform sm:gap-x-6"
animate={{ x: animateX }}
transition={{
@@ -58,13 +62,19 @@ function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowPr
},
}}
>
{templates.map(([template, metadata], index) => (
<TemplateItem key={`${rowId}-${template}-${index}`} metadata={metadata} />
{templates.map(({ id, metadata }) => (
<TemplateItem key={id} metadata={metadata} />
))}
</motion.div>
</m.div>
);
}
const createMarqueeItems = (entries: Array<[string, TemplateMetadata]>, rowId: string): TemplateMarqueeItem[] =>
entries.flatMap(([template, metadata]) => [
{ id: `${rowId}-${template}-primary`, metadata },
{ id: `${rowId}-${template}-repeat`, metadata },
]);
export function Templates() {
// Split templates into two rows and duplicate for seamless infinite scroll
const { row1, row2 } = useMemo(() => {
@@ -75,14 +85,14 @@ export function Templates() {
// Duplicate each row for seamless scrolling
return {
row1: [...firstHalf, ...firstHalf],
row2: [...secondHalf, ...secondHalf],
row1: createMarqueeItems(firstHalf, "row1"),
row2: createMarqueeItems(secondHalf, "row2"),
};
}, []);
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<motion.div
<m.div
className="space-y-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -99,16 +109,16 @@ export function Templates() {
personalities. Reactive Resume currently offers 12 templates, with more on the way.
</Trans>
</p>
</motion.div>
</m.div>
<div className="relative mt-8 -rotate-3 py-8 sm:-rotate-4 lg:mt-0 lg:-rotate-5">
{/* Marquee container with minimum height */}
<div className="flex min-h-[280px] flex-col gap-y-4 sm:min-h-[320px] sm:gap-y-6 md:min-h-[380px] lg:min-h-[420px]">
{/* First row - moves left to right */}
<MarqueeRow templates={row1} rowId="row1" direction="left" duration={45} />
<MarqueeRow templates={row1} direction="left" duration={45} />
{/* Second row - moves right to left (opposite direction) */}
<MarqueeRow templates={row2} rowId="row2" direction="right" duration={50} />
<MarqueeRow templates={row2} direction="right" duration={50} />
</div>
</div>
</section>
@@ -1,6 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { QuotesIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useMemo } from "react";
const email = "hello@amruthpillai.com";
@@ -35,7 +35,7 @@ type TestimonialCardProps = {
function TestimonialCard({ testimonial }: TestimonialCardProps) {
return (
<motion.div
<m.div
className="group relative flex w-full flex-col overflow-hidden text-pretty rounded-2xl border bg-card p-4 will-change-transform"
initial={{ scale: 1, boxShadow: "0 0 20px 0 rgba(0, 0, 0, 0)" }}
whileHover={{ scale: 1.2, zIndex: 100, boxShadow: "0 0 40px 0 rgba(0, 0, 0, 0.5)" }}
@@ -46,54 +46,62 @@ function TestimonialCard({ testimonial }: TestimonialCardProps) {
className="absolute -right-2 -bottom-4 size-18 opacity-10 transition-[bottom] duration-200 group-hover:-bottom-16"
/>
<p className="flex-1 text-muted-foreground leading-relaxed">{testimonial}</p>
</motion.div>
</m.div>
);
}
type TestimonialColumnProps = {
columnId: string;
id: string;
testimonials: string[];
};
function TestimonialColumn({ columnId, testimonials }: TestimonialColumnProps) {
function TestimonialColumn({ id, testimonials }: TestimonialColumnProps) {
return (
<div className="flex w-[320px] shrink-0 flex-col gap-y-4 sm:w-[360px] md:w-[400px]">
{testimonials.map((testimonial, index) => (
<TestimonialCard key={`${columnId}-${index}`} testimonial={testimonial} />
{testimonials.map((testimonial) => (
<TestimonialCard key={`${id}-${testimonial}`} testimonial={testimonial} />
))}
</div>
);
}
type TestimonialColumnData = {
id: string;
testimonials: string[];
};
type MarqueeMasonryProps = {
columns: string[][];
columns: TestimonialColumnData[];
direction: "left" | "right";
duration?: number;
};
function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryProps) {
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
const marqueeColumns = [...columns, ...columns];
const marqueeColumns = columns.flatMap((column) => [
{ ...column, id: `${column.id}-primary` },
{ ...column, id: `${column.id}-repeat` },
]);
return (
<motion.div
<m.div
className="flex items-start gap-x-4 will-change-transform"
animate={{ x: animateX }}
transition={{ x: { repeat: Number.POSITIVE_INFINITY, repeatType: "loop", duration, ease: "linear" } }}
>
{marqueeColumns.map((column, index) => (
<TestimonialColumn key={index} columnId={`column-${index}`} testimonials={column} />
{marqueeColumns.map((column) => (
<TestimonialColumn key={column.id} id={column.id} testimonials={column.testimonials} />
))}
</motion.div>
</m.div>
);
}
export function Testimonials() {
const columns = useMemo(() => {
const columns: string[][] = [];
const columns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
columns.push(testimonials.slice(index, index + 2));
columns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
return columns;
@@ -101,8 +109,8 @@ export function Testimonials() {
return (
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
<motion.div
className="mb-10 flex flex-col items-center space-y-4 px-4 text-center md:px-8"
<m.div
className="mb-10 flex flex-col items-center gap-y-4 px-4 text-center md:px-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@@ -128,7 +136,7 @@ export function Testimonials() {
.
</Trans>
</p>
</motion.div>
</m.div>
<div className="relative">
{/* Left fade */}