feat: replace format cloud with scrolling marquee rows in features section

Four animated marquee rows (Images, Documents, Audio/Video, Data/Fonts)
scroll continuously in alternating directions. Category labels pinned on
left with edge fade. Badges pause on hover. Each row has distinct color
and scroll speed.
This commit is contained in:
noah
2026-03-09 21:11:55 +01:00
parent a1e64225d5
commit 775a90a79b
2 changed files with 117 additions and 126 deletions
+24
View File
@@ -130,3 +130,27 @@ body {
background-position: right 10px center; background-position: right 10px center;
padding-right: 28px; padding-right: 28px;
} }
/* ---- Marquee scroll for format rows ---- */
@keyframes marquee-left {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes marquee-right {
from { transform: translateX(-50%); }
to { transform: translateX(0); }
}
.animate-marquee-left {
animation: marquee-left var(--marquee-duration, 30s) linear infinite;
}
.animate-marquee-right {
animation: marquee-right var(--marquee-duration, 30s) linear infinite;
}
.animate-marquee-left:hover,
.animate-marquee-right:hover {
animation-play-state: paused;
}
+86 -119
View File
@@ -51,68 +51,41 @@ const flowSteps = [
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' }, { inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
]; ];
/* ─── Format Cloud Data ─── */ /* ─── Format Marquee Data ─── */
type FormatCategory = 'image' | 'document' | 'audio' | 'video' | 'data'; const marqueeRows = [
{
const categoryMeta: Record<FormatCategory, { label: string; color: string; colorLight: string }> = { label: 'Images',
image: { label: 'Images', color: '#f472b6', colorLight: 'rgba(244,114,182,0.10)' }, color: '#f472b6',
document: { label: 'Documents', color: '#60a5fa', colorLight: 'rgba(96,165,250,0.10)' }, colorLight: 'rgba(244,114,182,0.08)',
audio: { label: 'Audio', color: '#a78bfa', colorLight: 'rgba(167,139,250,0.10)' }, formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC', 'BMP', 'TIFF', 'ICO'],
video: { label: 'Video', color: '#fb923c', colorLight: 'rgba(251,146,60,0.10)' }, direction: 'left' as const,
data: { label: 'Data & Fonts', color: '#34d399', colorLight: 'rgba(52,211,153,0.10)' }, speed: '35s',
}; },
{
const allFormats: { name: string; cat: FormatCategory; popular?: boolean }[] = [ label: 'Documents',
// Images color: '#60a5fa',
{ name: 'PNG', cat: 'image', popular: true }, colorLight: 'rgba(96,165,250,0.08)',
{ name: 'JPG', cat: 'image', popular: true }, formats: ['PDF', 'DOCX', 'MD', 'HTML', 'TXT', 'RTF', 'PPTX', 'EPUB'],
{ name: 'WebP', cat: 'image', popular: true }, direction: 'right' as const,
{ name: 'GIF', cat: 'image' }, speed: '30s',
{ name: 'AVIF', cat: 'image' }, },
{ name: 'SVG', cat: 'image', popular: true }, {
{ name: 'PSD', cat: 'image' }, label: 'Audio & Video',
{ name: 'HEIC', cat: 'image', popular: true }, color: '#a78bfa',
{ name: 'BMP', cat: 'image' }, colorLight: 'rgba(167,139,250,0.08)',
{ name: 'TIFF', cat: 'image' }, formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC', 'M4A', 'MP4', 'WebM', 'AVI', 'MOV', 'MKV'],
{ name: 'ICO', cat: 'image' }, direction: 'left' as const,
// Documents speed: '38s',
{ name: 'PDF', cat: 'document', popular: true }, },
{ name: 'DOCX', cat: 'document', popular: true }, {
{ name: 'MD', cat: 'document' }, label: 'Data & Fonts',
{ name: 'HTML', cat: 'document', popular: true }, color: '#34d399',
{ name: 'TXT', cat: 'document' }, colorLight: 'rgba(52,211,153,0.08)',
{ name: 'RTF', cat: 'document' }, formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TSV', 'TOML', 'INI', 'SQL', 'NDJSON', 'TTF', 'OTF', 'WOFF', 'WOFF2'],
{ name: 'PPTX', cat: 'document', popular: true }, direction: 'right' as const,
{ name: 'EPUB', cat: 'document' }, speed: '40s',
// Audio },
{ name: 'MP3', cat: 'audio', popular: true },
{ name: 'WAV', cat: 'audio', popular: true },
{ name: 'OGG', cat: 'audio' },
{ name: 'FLAC', cat: 'audio', popular: true },
{ name: 'AAC', cat: 'audio' },
{ name: 'M4A', cat: 'audio' },
// Video
{ name: 'MP4', cat: 'video', popular: true },
{ name: 'WebM', cat: 'video' },
{ name: 'AVI', cat: 'video' },
{ name: 'MOV', cat: 'video', popular: true },
{ name: 'MKV', cat: 'video', popular: true },
// Data & Fonts
{ name: 'CSV', cat: 'data', popular: true },
{ name: 'JSON', cat: 'data', popular: true },
{ name: 'XML', cat: 'data' },
{ name: 'YAML', cat: 'data', popular: true },
{ name: 'XLSX', cat: 'data', popular: true },
{ name: 'TSV', cat: 'data' },
{ name: 'TOML', cat: 'data' },
{ name: 'INI', cat: 'data' },
{ name: 'NDJSON', cat: 'data' },
{ name: 'SQL', cat: 'data' },
{ name: 'TTF', cat: 'data' },
{ name: 'OTF', cat: 'data' },
{ name: 'WOFF', cat: 'data' },
{ name: 'WOFF2', cat: 'data' },
]; ];
/* ─── Animation Variants ─── */ /* ─── Animation Variants ─── */
@@ -467,13 +440,13 @@ export default function LandingPage() {
<ConversionFlow /> <ConversionFlow />
</section> </section>
{/* ──── FEATURES — FORMAT CLOUD ──── */} {/* ──── FEATURES — SCROLLING MARQUEE ──── */}
<section <section
id="features" id="features"
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20" className="relative z-10 flex flex-col items-center gap-10 px-0 py-20 overflow-hidden"
> >
<motion.div <motion.div
className="text-center flex flex-col items-center gap-3" className="text-center flex flex-col items-center gap-3 px-6"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
@@ -490,72 +463,66 @@ export default function LandingPage() {
</p> </p>
</motion.div> </motion.div>
{/* Category legend */} {/* Marquee rows */}
<div className="w-full flex flex-col gap-3">
{marqueeRows.map((row, rowIndex) => (
<motion.div <motion.div
className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2" key={row.label}
className="relative"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }} whileInView={{ opacity: 1 }}
viewport={{ once: true }} viewport={{ once: true, margin: '-20px' }}
transition={{ delay: 0.2 }} transition={{ duration: 0.5, delay: rowIndex * 0.1 }}
> >
{(Object.entries(categoryMeta) as [FormatCategory, typeof categoryMeta[FormatCategory]][]).map(([key, meta]) => ( {/* Category label — pinned left */}
<div key={key} className="flex items-center gap-2"> <div className="absolute left-0 top-0 bottom-0 z-10 flex items-center pl-4 sm:pl-6">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: meta.color }} /> <span
<span className="font-mono text-[11px] font-semibold text-text-mid tracking-wide">{meta.label}</span> className="font-mono text-[10px] font-bold uppercase tracking-[0.1em] px-2.5 py-1 rounded-md backdrop-blur-sm"
</div>
))}
</motion.div>
{/* Format cloud */}
<motion.div
className="w-full max-w-[820px] flex flex-wrap items-center justify-center gap-2.5"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-40px' }}
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.02 } },
}}
>
{allFormats.map((fmt) => {
const meta = categoryMeta[fmt.cat];
return (
<motion.span
key={fmt.name}
className={`inline-flex items-center font-mono font-bold rounded-xl border cursor-default select-none transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
fmt.popular
? 'px-4 py-2 text-[13px] tracking-wide'
: 'px-3 py-1.5 text-[11px] tracking-wider'
}`}
style={{ style={{
color: meta.color, color: row.color,
background: meta.colorLight, background: `${row.colorLight}`,
borderColor: `${meta.color}20`, border: `1px solid ${row.color}18`,
}}
variants={{
hidden: { opacity: 0, scale: 0.7, y: 12 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const },
},
}}
whileHover={{
scale: 1.08,
background: `${meta.color}20`,
borderColor: `${meta.color}50`,
}} }}
> >
.{fmt.name} {row.label}
</motion.span> </span>
); </div>
})}
{/* Fade edges */}
<div className="absolute left-0 top-0 bottom-0 w-28 sm:w-40 z-[5] pointer-events-none bg-[linear-gradient(to_right,var(--color-bg-cream)_30%,transparent)]" />
<div className="absolute right-0 top-0 bottom-0 w-20 sm:w-32 z-[5] pointer-events-none bg-[linear-gradient(to_left,var(--color-bg-cream)_20%,transparent)]" />
{/* Scrolling track */}
<div className="overflow-hidden">
<div
className={`flex items-center gap-3 w-max ${
row.direction === 'left' ? 'animate-marquee-left' : 'animate-marquee-right'
}`}
style={{ '--marquee-duration': row.speed } as React.CSSProperties}
>
{/* Duplicate the badges for seamless loop */}
{[...row.formats, ...row.formats].map((fmt, i) => (
<span
key={`${fmt}-${i}`}
className="inline-flex items-center px-4 py-2 font-mono text-[12px] font-bold rounded-xl border whitespace-nowrap select-none transition-all duration-200 hover:scale-105 hover:-translate-y-0.5 hover:shadow-md cursor-default"
style={{
color: row.color,
background: row.colorLight,
borderColor: `${row.color}20`,
}}
>
.{fmt}
</span>
))}
</div>
</div>
</motion.div> </motion.div>
))}
</div>
{/* Total count callout */} {/* Total count callout */}
<motion.p <motion.p
className="text-sm text-text-light font-mono tracking-wide" className="text-sm text-text-light font-mono tracking-wide px-6"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }} whileInView={{ opacity: 1 }}
viewport={{ once: true }} viewport={{ once: true }}