- Replaced font references in globals.css to use DM Sans and JetBrains Mono for improved typography consistency. - Adjusted various components to utilize the new font styles, enhancing visual hierarchy and readability. - Updated layout properties in AppShell and Sidebar for better alignment and spacing. - Enhanced button styles to include a new subtle variant for improved UI flexibility. - Refactored SearchFilterBar to support active filter display, improving user interaction experience. - Made minor adjustments to the DashboardView and landing page components for better visual consistency.
17 KiB
Parallax Pinned Chapters + Snap Carousel Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Redesign the landing page with sticky "chapter" sections that pin and stack as users scroll, plus a native CSS scroll-snap carousel for services.
Architecture: Landing page sections grouped into 4 chapters wrapped in sticky containers with ascending z-index. Each chapter slides over the previous one. The services carousel is rebuilt from JS-driven absolute positioning to native CSS scroll-snap. A useSnapCarousel hook manages scroll state, auto-play, and indicator sync.
Tech Stack: Next.js 15, React 19, Tailwind CSS v4 (with @utility), CSS position: sticky, CSS scroll-snap-type
Design doc: docs/plans/2026-03-05-parallax-pinned-chapters-design.md
Task 1: Create the Chapter wrapper component
Files:
- Create:
apps/portal/src/features/landing-page/components/Chapter.tsx - Modify:
apps/portal/src/features/landing-page/components/index.ts
Step 1: Create Chapter.tsx
// apps/portal/src/features/landing-page/components/Chapter.tsx
import { cn } from "@/shared/utils";
interface ChapterProps {
children: React.ReactNode;
zIndex: number;
className?: string;
overlay?: boolean;
}
export function Chapter({ children, zIndex, className, overlay = false }: ChapterProps) {
return (
<section
className={cn(
"sticky top-0 will-change-transform",
"motion-safe:sticky motion-reduce:relative",
overlay && "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]",
className
)}
style={{ zIndex }}
>
{children}
</section>
);
}
Step 2: Add export to components/index.ts
Add this line to apps/portal/src/features/landing-page/components/index.ts:
export { Chapter } from "./Chapter";
Step 3: Verify types compile
Run: pnpm type-check
Expected: PASS (no type errors)
Step 4: Commit
feat: add Chapter sticky wrapper component
Task 2: Create the useSnapCarousel hook
Files:
- Create:
apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts - Modify:
apps/portal/src/features/landing-page/hooks/index.ts
Step 1: Create useSnapCarousel.ts
This hook manages a CSS scroll-snap carousel: tracks the active index via scroll position, provides goTo/goNext/goPrev via scrollTo, handles auto-play with pause-on-interaction, and syncs dot indicators.
// apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseSnapCarouselOptions {
total: number;
autoPlayMs?: number;
}
export function useSnapCarousel({ total, autoPlayMs = 10000 }: UseSnapCarouselOptions) {
const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Auto-play pause tracking
const pausedRef = useRef(false);
const pauseTimerRef = useRef<ReturnType<typeof setTimeout>>();
const pauseAutoPlay = useCallback(() => {
pausedRef.current = true;
clearTimeout(pauseTimerRef.current);
pauseTimerRef.current = setTimeout(() => {
pausedRef.current = false;
}, autoPlayMs * 2);
}, [autoPlayMs]);
// Sync activeIndex from scroll position
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const onScroll = () => {
const scrollLeft = container.scrollLeft;
const cardWidth = container.offsetWidth;
if (cardWidth === 0) return;
const index = Math.round(scrollLeft / cardWidth);
setActiveIndex(Math.min(index, total - 1));
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
}, [total]);
const scrollToIndex = useCallback((index: number) => {
const container = scrollRef.current;
if (!container) return;
const cardWidth = container.offsetWidth;
container.scrollTo({ left: index * cardWidth, behavior: "smooth" });
}, []);
const goTo = useCallback(
(index: number) => {
pauseAutoPlay();
scrollToIndex(index);
},
[pauseAutoPlay, scrollToIndex]
);
const goNext = useCallback(() => {
pauseAutoPlay();
const next = (activeIndex + 1) % total;
scrollToIndex(next);
}, [activeIndex, total, pauseAutoPlay, scrollToIndex]);
const goPrev = useCallback(() => {
pauseAutoPlay();
const prev = (activeIndex - 1 + total) % total;
scrollToIndex(prev);
}, [activeIndex, total, pauseAutoPlay, scrollToIndex]);
const reset = useCallback(() => {
const container = scrollRef.current;
if (!container) return;
container.scrollTo({ left: 0, behavior: "instant" });
setActiveIndex(0);
}, []);
// Auto-play
useEffect(() => {
if (total <= 1) return;
const id = setInterval(() => {
if (pausedRef.current) return;
const container = scrollRef.current;
if (!container) return;
const cardWidth = container.offsetWidth;
const currentIndex = Math.round(container.scrollLeft / cardWidth);
const next = (currentIndex + 1) % total;
container.scrollTo({ left: next * cardWidth, behavior: "smooth" });
}, autoPlayMs);
return () => clearInterval(id);
}, [total, autoPlayMs]);
// Pause on touch/pointer interaction
const onPointerDown = useCallback(() => {
pauseAutoPlay();
}, [pauseAutoPlay]);
// Cleanup
useEffect(() => {
return () => clearTimeout(pauseTimerRef.current);
}, []);
// Keyboard navigation
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
},
[goPrev, goNext]
);
return {
scrollRef,
activeIndex,
total,
goTo,
goNext,
goPrev,
reset,
onPointerDown,
onKeyDown,
};
}
Step 2: Update hooks/index.ts
Add this export to apps/portal/src/features/landing-page/hooks/index.ts:
export { useSnapCarousel } from "./useSnapCarousel";
Step 3: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 4: Commit
feat: add useSnapCarousel hook for CSS scroll-snap carousel
Task 3: Rebuild ServicesCarousel with CSS scroll-snap
Files:
- Modify:
apps/portal/src/features/landing-page/components/ServicesCarousel.tsx
Context: The current carousel uses absolute positioning with JS transform calculations (getCircularOffset, translateX(${offset * 102}%)). Replace with a native horizontal scroll container using CSS scroll-snap.
Step 1: Rewrite ServicesCarousel.tsx
Key changes:
- Replace
useCarouselwithuseSnapCarousel - Replace the absolute-positioned card layout with a horizontal flex scroll container
- Add
scroll-snap-type: x mandatoryon the container - Add
scroll-snap-align: centeron each card - Each card is
min-w-full(takes full width of the scroll viewport) - Remove the invisible sizer div hack
- Remove
onTouchStart/onTouchEnd(native scroll handles swipe) - Remove
getCircularOffsethelper - Keep:
CarouselHeader,CarouselNav,ServiceCard(unchanged),ACCENTSmap - Keep:
useInViewfor the section fade-in animation - Remove the
full-bleedclass from the section (Chapter wrapper will handle full-width)
The scroll container structure:
<div
ref={c.scrollRef}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
onPointerDown={c.onPointerDown}
onKeyDown={c.onKeyDown}
tabIndex={0}
role="region"
aria-label="Services carousel"
aria-roledescription="carousel"
>
{cards.map((card, i) => (
<div
key={`${card.title}-${i}`}
className="min-w-full snap-center px-6 sm:px-10"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${cards.length}: ${card.title}`}
>
<div className="mx-auto max-w-3xl">
<ServiceCard card={card} />
</div>
</div>
))}
</div>
Also add scrollbar-hide utility if not already present. Check apps/portal/src/styles/utilities.css for existing scrollbar-hide. If missing, add to the same file:
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
Step 2: Remove full-bleed from section
The section should no longer use full-bleed since the Chapter wrapper handles the full-width layout. Change the section's className from:
"full-bleed bg-surface-sunken/30 py-16 sm:py-20 ..."
to:
"bg-surface-sunken/30 py-16 sm:py-20 ..."
Step 3: Wire up useSnapCarousel
const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });
Call c.reset() when activeTab changes (same as before).
Step 4: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 5: Commit
refactor: rebuild ServicesCarousel with CSS scroll-snap
Task 4: Update HeroSection and TrustStrip for Chapter 1
Files:
- Modify:
apps/portal/src/features/landing-page/components/HeroSection.tsx - Modify:
apps/portal/src/features/landing-page/components/TrustStrip.tsx
Context: Chapter 1 wraps Hero + TrustStrip. The chapter is min-h-dvh flex flex-col. Hero should flex-grow to fill available space, TrustStrip anchors at the bottom.
Step 1: Update HeroSection
- Remove
full-bleedfrom the section (Chapter wrapper handles full-width) - Add
flex-1so it expands to fill the chapter - Change the section to a
div(the Chapter<section>is the semantic wrapper now) - Keep the gradient background, dot pattern, and all content unchanged
Change:
<section ref={heroRef} className={cn("full-bleed py-16 sm:py-20 lg:py-24 ...")}>
To:
<div ref={heroRef} className={cn("relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 ...")}>
Step 2: Update TrustStrip
- Remove
full-bleedfrom the section - Change to a
div(Chapter is the semantic<section>) - Keep all content and animation unchanged
Change:
<section ref={ref} aria-label="Company statistics" className={cn("full-bleed py-10 sm:py-12 ...")}>
To:
<div ref={ref} aria-label="Company statistics" className={cn("relative py-10 sm:py-12 ...")}>
Step 3: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 4: Commit
refactor: adjust HeroSection and TrustStrip for Chapter 1 layout
Task 5: Update WhyUsSection and CTABanner for Chapter 3
Files:
- Modify:
apps/portal/src/features/landing-page/components/WhyUsSection.tsx - Modify:
apps/portal/src/features/landing-page/components/CTABanner.tsx
Step 1: Update WhyUsSection
- Remove
full-bleedfrom the section - Change
<section>to<div> - Keep all content unchanged
Step 2: Update CTABanner
- Remove
full-bleedfrom the section - Change
<section>to<div> - Keep all content unchanged
Step 3: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 4: Commit
refactor: adjust WhyUsSection and CTABanner for Chapter 3 layout
Task 6: Update SupportDownloadsSection and ContactSection for Chapter 4
Files:
- Modify:
apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx - Modify:
apps/portal/src/features/landing-page/components/ContactSection.tsx
Step 1: Update ContactSection
- Remove
full-bleedfrom the section - Change
<section>to<div>(or keep as<section>since Chapter 4 is a plain<div>)
Actually, Chapter 4 is NOT sticky (it's the last chapter, just a plain wrapper). So ContactSection and SupportDownloadsSection can remain as <section> elements. But still remove full-bleed since the Chapter 4 wrapper handles layout.
- ContactSection: remove
full-bleedfrom className - SupportDownloadsSection: no
full-bleedto remove (it doesn't use it), no changes needed
Step 2: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 3: Commit
refactor: adjust ContactSection for Chapter 4 layout
Task 7: Wire up PublicLandingView with Chapter wrappers
Files:
- Modify:
apps/portal/src/features/landing-page/views/PublicLandingView.tsx
Context: This is the main composition file. Wrap section groups in Chapter components.
Step 1: Rewrite PublicLandingView.tsx
"use client";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { useStickyCta } from "@/features/landing-page/hooks";
import {
Chapter,
HeroSection,
TrustStrip,
ServicesCarousel,
WhyUsSection,
CTABanner,
SupportDownloadsSection,
ContactSection,
} from "@/features/landing-page/components";
export function PublicLandingView() {
const { heroCTARef, showStickyCTA } = useStickyCta();
return (
<div className="pb-8">
{/* Chapter 1: Who we are */}
<Chapter zIndex={1} className="min-h-dvh flex flex-col bg-background">
<HeroSection heroCTARef={heroCTARef} />
<TrustStrip />
</Chapter>
{/* Chapter 2: What we offer */}
<Chapter zIndex={2} overlay className="bg-surface-sunken/30">
<ServicesCarousel />
</Chapter>
{/* Chapter 3: Why choose us */}
<Chapter zIndex={3} overlay className="bg-background">
<WhyUsSection />
<CTABanner />
</Chapter>
{/* Chapter 4: Get in touch (no sticky, last chapter) */}
<div className="relative bg-background" style={{ zIndex: 4 }}>
<SupportDownloadsSection />
<ContactSection />
</div>
{/* Sticky Mobile CTA */}
{showStickyCTA && (
<div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t border-border p-4 z-50 md:hidden animate-in slide-in-from-bottom-4 duration-300">
<Button
as="a"
href="/services"
variant="pill"
size="lg"
rightIcon={<ArrowRight className="h-5 w-5" />}
className="w-full shadow-lg"
>
Find Your Plan
</Button>
</div>
)}
</div>
);
}
Key changes:
- Each section group wrapped in
Chapterwith ascendingzIndex - Chapter 1 gets
min-h-dvh flex flex-colto fill viewport - Chapters 2 and 3 get
overlayprop for the top shadow - Each Chapter gets a solid
bg-*class so it covers the chapter behind - Chapter 4 is a plain
divwithzIndex: 4andrelativepositioning - Removed
space-y-0 pt-0from outer container (chapters handle spacing)
Step 2: Verify types compile
Run: pnpm type-check
Expected: PASS
Step 3: Commit
feat: wire up PublicLandingView with sticky Chapter layout
Task 8: Visual QA and polish
Files:
- Potentially any of the above files for tweaks
Step 1: Run the dev server and test
Run: pnpm --filter @customer-portal/portal dev (with user permission)
Step 2: Visual checklist
Test in browser at localhost:3000:
- Chapter 1 (Hero + TrustStrip) fills the viewport and pins
- Scrolling down: Chapter 2 (Services) slides up and covers Chapter 1
- Services carousel: swipe/drag snaps cards into place
- Services carousel: dot indicators stay in sync
- Services carousel: arrow buttons work
- Services carousel: personal/business tab toggle resets to first card
- Services carousel: auto-play advances cards
- Chapter 3 (WhyUs + CTA) slides up and covers Chapter 2
- Chapter 4 (Support + Contact) scrolls normally
- Contact form is fully interactive (not blocked by sticky)
- Sticky mobile CTA still appears when hero scrolls out of view
prefers-reduced-motion: sticky behavior disabled, normal scroll- No horizontal overflow / layout shifts
- Test on mobile viewport (responsive)
Step 3: Fix any issues found
Adjust padding, backgrounds, shadows, z-index as needed.
Step 4: Run lint and type-check
Run: pnpm lint && pnpm type-check
Expected: PASS
Step 5: Commit
style: polish parallax chapters and snap carousel
Task 9: Clean up deprecated code
Files:
- Modify:
apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts - Modify:
apps/portal/src/features/landing-page/hooks/index.ts
Step 1: Check if useCarousel / useInfiniteCarousel is used elsewhere
Search for imports of useCarousel and useInfiniteCarousel across the codebase. If only used in the old ServicesCarousel (now replaced), remove the file.
Step 2: If unused, delete useInfiniteCarousel.ts
Remove the file and remove its exports from hooks/index.ts.
Step 3: Run lint and type-check
Run: pnpm lint && pnpm type-check
Expected: PASS
Step 4: Commit
chore: remove unused useCarousel hook (replaced by useSnapCarousel)