Add scroll-reveal animations across home, services, portfolio, about, apps

New ScrollReveal client component wraps section-containers and cards with
a subtle opacity + y-translate fade-in triggered by IntersectionObserver.
Bails out to an unanimated render when prefers-reduced-motion is set.

- home: Selected Works, Inclusive by Design, Start a Project
- services: each of the four verticals and the Recent Work card
- portfolio: each product card, slight stagger by column index
- about: Leadership section and Get in Touch
- apps: each app card, stagger by index
This commit is contained in:
jwinterm
2026-04-20 13:28:03 -04:00
parent 7eb531170d
commit 5c1e3d597f
6 changed files with 160 additions and 72 deletions
+14 -11
View File
@@ -2,6 +2,7 @@ import { FounderSignature } from "@/components/about/founder-signature";
import { ContactForm } from "@/components/contact/contact-form"; import { ContactForm } from "@/components/contact/contact-form";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { ScrollReveal } from "@/components/scroll-reveal";
import { FaMapMarkerAlt, FaPhone, FaBuilding, FaExternalLinkAlt } from "react-icons/fa"; import { FaMapMarkerAlt, FaPhone, FaBuilding, FaExternalLinkAlt } from "react-icons/fa";
import { FaXTwitter, FaTelegram, FaDiscord, FaBluesky, FaTiktok, FaYoutube, FaInstagram, FaLinkedinIn, FaFacebookF, FaPinterestP } from "react-icons/fa6"; import { FaXTwitter, FaTelegram, FaDiscord, FaBluesky, FaTiktok, FaYoutube, FaInstagram, FaLinkedinIn, FaFacebookF, FaPinterestP } from "react-icons/fa6";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -117,20 +118,22 @@ export default function AboutPage() {
</div> </div>
</div> </div>
<div className="border-t border-border/50 pt-10"> <ScrollReveal className="border-t border-border/50 pt-10">
<h2 className="text-2xl font-bold mb-6 text-center">Leadership</h2> <h2 className="text-2xl font-bold mb-6 text-center">Leadership</h2>
<FounderSignature /> <FounderSignature />
</ScrollReveal>
</div>
<ScrollReveal as="section" className="mt-16 border-t border-border/50 pt-16">
<div id="contact">
<h2 className="text-3xl font-bold mb-3 tracking-tight">Get in Touch</h2>
<p className="text-muted-foreground text-lg mb-12 max-w-2xl">
Have a project in mind? We'd love to hear about it.
</p>
<ContactForm />
</div> </div>
</ScrollReveal>
</div>
<div id="contact" className="mt-16 border-t border-border/50 pt-16">
<h2 className="text-3xl font-bold mb-3 tracking-tight">Get in Touch</h2>
<p className="text-muted-foreground text-lg mb-12 max-w-2xl">
Have a project in mind? We'd love to hear about it.
</p>
<ContactForm />
</div>
</div> </div>
<MobileNav /> <MobileNav />
</main> </main>
+20 -17
View File
@@ -1,5 +1,6 @@
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { ScrollReveal } from "@/components/scroll-reveal";
import { StoreButtons } from "@/components/store-buttons"; import { StoreButtons } from "@/components/store-buttons";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -65,25 +66,27 @@ export default function LinksPage() {
<p className="text-muted-foreground text-center mb-10">Our apps</p> <p className="text-muted-foreground text-center mb-10">Our apps</p>
<div className="space-y-8"> <div className="space-y-8">
{apps.map((app) => ( {apps.map((app, i) => (
<div key={app.name} className={`rounded-xl border ${app.borderColor} bg-card p-6`}> <ScrollReveal key={app.name} delay={i * 0.08}>
<div className="flex items-center gap-4 mb-3"> <div className={`rounded-xl border ${app.borderColor} bg-card p-6`}>
<Image <div className="flex items-center gap-4 mb-3">
src={app.icon} <Image
alt={`${app.name} icon`} src={app.icon}
width={56} alt={`${app.name} icon`}
height={56} width={56}
className="rounded-xl" height={56}
/> className="rounded-xl"
<div> />
<Link href={app.href}> <div>
<h2 className={`text-xl font-bold ${app.color} hover:underline`}>{app.name}</h2> <Link href={app.href}>
</Link> <h2 className={`text-xl font-bold ${app.color} hover:underline`}>{app.name}</h2>
<p className="text-sm text-muted-foreground">{app.tagline}</p> </Link>
<p className="text-sm text-muted-foreground">{app.tagline}</p>
</div>
</div> </div>
<StoreButtons links={app.links} />
</div> </div>
<StoreButtons links={app.links} /> </ScrollReveal>
</div>
))} ))}
</div> </div>
+8 -7
View File
@@ -7,6 +7,7 @@ import { ServicesPreview } from "@/components/landing/services-preview";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { DynamicBackground } from "@/components/landing/dynamic-background"; import { DynamicBackground } from "@/components/landing/dynamic-background";
import { ScrollReveal } from "@/components/scroll-reveal";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@repo/ui/components/button"; import { Button } from "@repo/ui/components/button";
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
@@ -83,7 +84,7 @@ export default function Home() {
<ServicesPreview /> <ServicesPreview />
<div className="w-full max-w-6xl mx-auto mb-16 px-4"> <ScrollReveal className="w-full max-w-6xl mx-auto mb-16 px-4">
<div className="text-left mb-8 section-container border-l-4 border-l-blue-500"> <div className="text-left mb-8 section-container border-l-4 border-l-blue-500">
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Selected Works</h2> <h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Selected Works</h2>
<p className="text-muted-foreground text-lg max-w-3xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-3xl leading-relaxed">
@@ -91,9 +92,9 @@ export default function Home() {
</p> </p>
</div> </div>
<PortfolioGrid /> <PortfolioGrid />
</div> </ScrollReveal>
<div className="w-full max-w-6xl mx-auto mb-20 px-4"> <ScrollReveal className="w-full max-w-6xl mx-auto mb-20 px-4">
<div className="text-left mb-12 section-container border-l-4 border-l-purple-500"> <div className="text-left mb-12 section-container border-l-4 border-l-purple-500">
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Inclusive by Design</h2> <h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Inclusive by Design</h2>
<p className="text-muted-foreground text-lg max-w-3xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-3xl leading-relaxed">
@@ -102,17 +103,17 @@ export default function Home() {
</p> </p>
</div> </div>
<FounderSignature /> <FounderSignature />
</div> </ScrollReveal>
<div id="contact" className="w-full mt-20 mb-20 pb-16 md:pb-0 flex flex-col items-center"> <ScrollReveal as="section" className="w-full mt-20 mb-20 pb-16 md:pb-0 flex flex-col items-center">
<div className="w-full max-w-6xl text-left mb-12 section-container border-l-4 border-l-cyan-500"> <div id="contact" className="w-full max-w-6xl text-left mb-12 section-container border-l-4 border-l-cyan-500">
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Start a Project</h2> <h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Start a Project</h2>
<p className="text-muted-foreground text-lg max-w-3xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-3xl leading-relaxed">
Have a project in mind? Let's talk about what you need and how we can help. Have a project in mind? Let's talk about what you need and how we can help.
</p> </p>
</div> </div>
<ContactForm /> <ContactForm />
</div> </ScrollReveal>
</div> </div>
<MobileNav /> <MobileNav />
</main> </main>
+23 -20
View File
@@ -1,5 +1,6 @@
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { ScrollReveal } from "@/components/scroll-reveal";
import { Card } from "@repo/ui/components/card"; import { Card } from "@repo/ui/components/card";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -112,26 +113,28 @@ export default function ProductsPage() {
</div> </div>
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
{products.map((product) => ( {products.map((product, i) => (
<Link key={product.slug} href={`/products/${product.slug}`}> <ScrollReveal key={product.slug} delay={(i % 2) * 0.08}>
<Card className={`glass-card h-full min-h-[200px] ${colorClasses[product.color]} transition-all cursor-pointer`}> <Link href={`/products/${product.slug}`}>
<div className="flex items-center gap-4 mb-3"> <Card className={`glass-card h-full min-h-[200px] ${colorClasses[product.color]} transition-all cursor-pointer`}>
<Image <div className="flex items-center gap-4 mb-3">
src={product.icon} <Image
alt={`${product.title} icon`} src={product.icon}
width={56} alt={`${product.title} icon`}
height={56} width={56}
className="rounded-xl flex-shrink-0" height={56}
/> className="rounded-xl flex-shrink-0"
<h2 className={`text-2xl font-bold ${titleColors[product.color]}`}> />
{product.title} <h2 className={`text-2xl font-bold ${titleColors[product.color]}`}>
</h2> {product.title}
</div> </h2>
<p className="text-muted-foreground leading-relaxed"> </div>
{product.description} <p className="text-muted-foreground leading-relaxed">
</p> {product.description}
</Card> </p>
</Link> </Card>
</Link>
</ScrollReveal>
))} ))}
</div> </div>
+13 -12
View File
@@ -2,6 +2,7 @@ import { DataGrid } from "@repo/ui/procedural/data-grid";
import { Button } from "@repo/ui/components/button"; import { Button } from "@repo/ui/components/button";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav"; import { MobileNav } from "@/components/layout/mobile-nav";
import { ScrollReveal } from "@/components/scroll-reveal";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Card } from "@repo/ui/components/card"; import { Card } from "@repo/ui/components/card";
@@ -38,7 +39,7 @@ export default function ServicesPage() {
</div> </div>
{/* Philosophy Card */} {/* Philosophy Card */}
<div className="mb-20"> <ScrollReveal className="mb-20">
<div className="text-left section-container border-l-4 border-l-emerald-500"> <div className="text-left section-container border-l-4 border-l-emerald-500">
<h2 className="text-3xl font-bold mb-2">Partnership-First Development</h2> <h2 className="text-3xl font-bold mb-2">Partnership-First Development</h2>
<p className="text-muted-foreground text-lg max-w-3xl"> <p className="text-muted-foreground text-lg max-w-3xl">
@@ -46,10 +47,10 @@ export default function ServicesPage() {
we provide the engineering. We design systems that grow with your business. we provide the engineering. We design systems that grow with your business.
</p> </p>
</div> </div>
</div> </ScrollReveal>
{/* E-Commerce Section */} {/* E-Commerce Section */}
<div className="mb-24"> <ScrollReveal className="mb-24">
<div className="text-left mb-10 section-container border-l-4 border-l-blue-500"> <div className="text-left mb-10 section-container border-l-4 border-l-blue-500">
<h2 className="text-3xl font-bold mb-2">Custom E-Commerce Engines</h2> <h2 className="text-3xl font-bold mb-2">Custom E-Commerce Engines</h2>
<p className="text-muted-foreground text-lg max-w-3xl"> <p className="text-muted-foreground text-lg max-w-3xl">
@@ -80,10 +81,10 @@ export default function ServicesPage() {
</p> </p>
</Card> </Card>
</div> </div>
</div> </ScrollReveal>
{/* Custom Apps Section */} {/* Custom Apps Section */}
<div className="mb-24"> <ScrollReveal className="mb-24">
<div className="text-left mb-10 section-container border-l-4 border-l-purple-500"> <div className="text-left mb-10 section-container border-l-4 border-l-purple-500">
<h2 className="text-3xl font-bold mb-2">Custom Apps & Interactive Websites</h2> <h2 className="text-3xl font-bold mb-2">Custom Apps & Interactive Websites</h2>
<p className="text-muted-foreground text-lg max-w-2xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-2xl leading-relaxed">
@@ -106,10 +107,10 @@ export default function ServicesPage() {
</p> </p>
</Card> </Card>
</div> </div>
</div> </ScrollReveal>
{/* Crypto Section */} {/* Crypto Section */}
<div className="mb-24"> <ScrollReveal className="mb-24">
<div className="text-left mb-10 section-container border-l-4 border-l-cyan-500"> <div className="text-left mb-10 section-container border-l-4 border-l-cyan-500">
<h2 className="text-3xl font-bold mb-2">Cryptocurrency & Payment Consulting</h2> <h2 className="text-3xl font-bold mb-2">Cryptocurrency & Payment Consulting</h2>
<p className="text-muted-foreground text-lg max-w-2xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-2xl leading-relaxed">
@@ -132,10 +133,10 @@ export default function ServicesPage() {
</p> </p>
</Card> </Card>
</div> </div>
</div> </ScrollReveal>
{/* Recent Work */} {/* Recent Work */}
<div className="mb-24"> <ScrollReveal className="mb-24">
<div className="text-left mb-10 section-container border-l-4 border-l-amber-500"> <div className="text-left mb-10 section-container border-l-4 border-l-amber-500">
<h2 className="text-3xl font-bold mb-2">Recent Work</h2> <h2 className="text-3xl font-bold mb-2">Recent Work</h2>
<p className="text-muted-foreground text-lg max-w-2xl leading-relaxed"> <p className="text-muted-foreground text-lg max-w-2xl leading-relaxed">
@@ -167,9 +168,9 @@ export default function ServicesPage() {
With us since day one. Five years on WordPress, migrating to a custom Medusa v2 storefront this spring. With us since day one. Five years on WordPress, migrating to a custom Medusa v2 storefront this spring.
</p> </p>
</Card> </Card>
</div> </ScrollReveal>
<div className="mt-20 p-16 rounded-[2.5rem] bg-gradient-to-br from-slate-900 via-slate-800 to-indigo-950 text-white text-center shadow-2xl relative overflow-hidden group border-2 border-white/5"> <ScrollReveal className="mt-20 p-16 rounded-[2.5rem] bg-gradient-to-br from-slate-900 via-slate-800 to-indigo-950 text-white text-center shadow-2xl relative overflow-hidden group border-2 border-white/5">
<div className="absolute inset-0 bg-blue-500/10 opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-0 bg-blue-500/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<h2 className="text-4xl font-bold mb-6 relative z-10 tracking-tight">Let's build together</h2> <h2 className="text-4xl font-bold mb-6 relative z-10 tracking-tight">Let's build together</h2>
<p className="mb-10 text-slate-300 text-lg max-w-2xl mx-auto relative z-10 leading-relaxed font-medium"> <p className="mb-10 text-slate-300 text-lg max-w-2xl mx-auto relative z-10 leading-relaxed font-medium">
@@ -181,7 +182,7 @@ export default function ServicesPage() {
Request a Consultation Request a Consultation
</Button> </Button>
</Link> </Link>
</div> </ScrollReveal>
</div> </div>
<MobileNav /> <MobileNav />
</main> </main>
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { motion, useReducedMotion, type Variants } from 'framer-motion';
import { createElement, type ReactNode } from 'react';
type Direction = 'up' | 'down' | 'left' | 'right' | 'none';
type Tag = 'div' | 'section' | 'article' | 'li';
interface ScrollRevealProps {
children: ReactNode;
direction?: Direction;
delay?: number;
duration?: number;
distance?: number;
amount?: number;
className?: string;
as?: Tag;
}
const axisFor = (direction: Direction, distance: number) => {
switch (direction) {
case 'up': return { x: 0, y: distance };
case 'down': return { x: 0, y: -distance };
case 'left': return { x: distance, y: 0 };
case 'right': return { x: -distance, y: 0 };
case 'none': return { x: 0, y: 0 };
}
};
export function ScrollReveal({
children,
direction = 'up',
delay = 0,
duration = 0.6,
distance = 24,
amount = 0.2,
className,
as = 'div',
}: ScrollRevealProps) {
const prefersReducedMotion = useReducedMotion();
if (prefersReducedMotion) {
return createElement(as, { className }, children);
}
const offset = axisFor(direction, distance);
const variants: Variants = {
hidden: { opacity: 0, ...offset },
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
delay,
ease: [0.16, 1, 0.3, 1],
},
},
};
const common = {
className,
initial: 'hidden',
whileInView: 'visible',
viewport: { once: true, amount },
variants,
children,
};
const Component =
as === 'section' ? motion.section :
as === 'article' ? motion.article :
as === 'li' ? motion.li :
motion.div;
return createElement(Component as never, common);
}