Assist_Design/docs/plans/2026-03-04-trust-strip-redesign.md
barsa a0f97cdec4 style: update background gradients and refactor component references
- Replaced oklch color definitions with color-mix for improved gradient consistency across OnsiteSupportContent, HeroSection, and ServicesOverviewContent components.
- Refactored ref attributes in multiple sections to remove unnecessary type assertions for better type safety.
- Enhanced ServicesCarousel component by introducing a new accent color system for better visual clarity and consistency.
2026-03-04 17:13:07 +09:00

9.8 KiB

TrustStrip Redesign — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the minimal icon+text trust strip with a bold stats section featuring large animated count-up numbers, icon circles, vertical dividers, and a gradient background.

Architecture: 3-file change — new useCountUp hook for the animation, rewritten TrustStrip.tsx component, barrel file update. Uses existing useInView hook and codebase patterns (cn utility, Tailwind, lucide-react). No new dependencies.

Tech Stack: React 19, Tailwind CSS, lucide-react, existing useInView hook


Task 1: Create the useCountUp hook

Files:

  • Create: apps/portal/src/features/landing-page/hooks/useCountUp.ts

Step 1: Write the hook

Create apps/portal/src/features/landing-page/hooks/useCountUp.ts:

import { useEffect, useState } from "react";

/**
 * useCountUp — Animates a number from 0 to target over a duration.
 * Respects prefers-reduced-motion. Only runs when enabled is true.
 */
export function useCountUp(target: number, duration = 1500, enabled = false): number {
  const [value, setValue] = useState(0);

  useEffect(() => {
    if (!enabled) return;

    // Respect prefers-reduced-motion
    const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (prefersReduced) {
      setValue(target);
      return;
    }

    let startTime: number | null = null;
    let rafId: number;

    const animate = (timestamp: number) => {
      if (!startTime) startTime = timestamp;
      const elapsed = timestamp - startTime;
      const progress = Math.min(elapsed / duration, 1);

      // Ease-out cubic for smooth deceleration
      const eased = 1 - Math.pow(1 - progress, 3);
      setValue(Math.round(eased * target));

      if (progress < 1) {
        rafId = requestAnimationFrame(animate);
      }
    };

    rafId = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(rafId);
  }, [target, duration, enabled]);

  return value;
}

Step 2: Export from barrel file

Modify apps/portal/src/features/landing-page/hooks/index.ts — add this line:

export { useCountUp } from "./useCountUp";

Step 3: Verify it builds

Run: pnpm type-check Expected: No errors

Step 4: Commit

git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/hooks/index.ts
git commit -m "feat: add useCountUp hook for animated number transitions"

Task 2: Rewrite TrustStrip component

Files:

  • Modify: apps/portal/src/features/landing-page/components/TrustStrip.tsx (complete rewrite)

Step 1: Replace TrustStrip.tsx with new implementation

Rewrite apps/portal/src/features/landing-page/components/TrustStrip.tsx:

"use client";

import { Clock, CreditCard, Globe, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView, useCountUp } from "@/features/landing-page/hooks";

interface StatItem {
  icon: LucideIcon;
  value: number;
  suffix: string;
  label: string;
  delay: number;
  formatter?: (n: number) => string;
}

const stats: StatItem[] = [
  { icon: Clock, value: 20, suffix: "+", label: "Years in Japan", delay: 0 },
  { icon: Globe, value: 100, suffix: "%", label: "English Support", delay: 100 },
  {
    icon: Users,
    value: 10000,
    suffix: "+",
    label: "Customers Served",
    delay: 200,
    formatter: (n: number) => n.toLocaleString(),
  },
];

function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
  const count = useCountUp(stat.value, 1500, inView);

  return (
    <span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums">
      {stat.formatter ? stat.formatter(count) : count}
      {stat.suffix}
    </span>
  );
}

export function TrustStrip() {
  const [ref, inView] = useInView();

  return (
    <section
      ref={ref as React.RefObject<HTMLElement>}
      aria-label="Company statistics"
      className={cn(
        "relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-10 sm:py-12 overflow-hidden transition-all duration-700",
        inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
      )}
    >
      {/* Gradient background */}
      <div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />

      <div className="relative mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
        <div className="grid grid-cols-2 gap-8 sm:flex sm:justify-between sm:items-center">
          {/* Animated stats */}
          {stats.map((stat, i) => (
            <div
              key={stat.label}
              className={cn(
                "flex flex-col items-center text-center gap-3 sm:flex-1",
                i < stats.length - 1 && "sm:border-r sm:border-border/30"
              )}
            >
              <div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
                <stat.icon className="h-5 w-5 text-primary" />
              </div>
              <div className="flex flex-col items-center gap-1">
                <AnimatedStat stat={stat} inView={inView} />
                <span className="text-sm text-muted-foreground font-medium">{stat.label}</span>
              </div>
            </div>
          ))}

          {/* Static stat — Foreign Cards */}
          <div className={cn("flex flex-col items-center text-center gap-3 sm:flex-1")}>
            <div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
              <CreditCard className="h-5 w-5 text-primary" />
            </div>
            <div className="flex flex-col items-center gap-1">
              <span className="text-xl sm:text-2xl font-extrabold text-primary">Foreign Cards</span>
              <span className="text-sm text-muted-foreground font-medium">Accepted</span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Key decisions:

  • AnimatedStat is a separate component so each stat has its own useCountUp instance
  • The 4th stat (Foreign Cards) is rendered separately since it has no numeric animation
  • Desktop: flex row with border-r dividers on first 3 items. Mobile: 2-col grid
  • tabular-nums prevents layout jitter during count-up animation
  • formatter on the 10,000 stat adds comma separators

Step 2: Verify it builds

Run: pnpm type-check Expected: No errors

Step 3: Visual check

Open localhost:3000 and verify:

  • 4 stats displayed in a row on desktop, 2x2 grid on mobile
  • Numbers animate from 0 to target when scrolling into view
  • Vertical dividers between first 3 stats on desktop
  • "Foreign Cards / Accepted" shows as static bold text
  • Gradient background blends with hero above
  • Each stat has a primary-tinted circular icon above it

Step 4: Commit

git add apps/portal/src/features/landing-page/components/TrustStrip.tsx
git commit -m "feat: redesign TrustStrip with bold animated stats"

Task 3: Stagger the count-up animations

The current implementation starts all counters simultaneously. Add stagger delays.

Files:

  • Modify: apps/portal/src/features/landing-page/hooks/useCountUp.ts
  • Modify: apps/portal/src/features/landing-page/components/TrustStrip.tsx

Step 1: Add delay parameter to useCountUp

In useCountUp.ts, change the signature and add a delay before animation starts:

export function useCountUp(target: number, duration = 1500, enabled = false, delay = 0): number {

Update the effect body — wrap the animation start in a setTimeout:

useEffect(() => {
  if (!enabled) return;

  const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (prefersReduced) {
    setValue(target);
    return;
  }

  let startTime: number | null = null;
  let rafId: number;
  let timeoutId: ReturnType<typeof setTimeout>;

  const animate = (timestamp: number) => {
    if (!startTime) startTime = timestamp;
    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = 1 - Math.pow(1 - progress, 3);
    setValue(Math.round(eased * target));

    if (progress < 1) {
      rafId = requestAnimationFrame(animate);
    }
  };

  timeoutId = setTimeout(() => {
    rafId = requestAnimationFrame(animate);
  }, delay);

  return () => {
    clearTimeout(timeoutId);
    cancelAnimationFrame(rafId);
  };
}, [target, duration, enabled, delay]);

Step 2: Pass delay from TrustStrip

In AnimatedStat, update the useCountUp call:

function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
  const count = useCountUp(stat.value, 1500, inView, stat.delay);

Step 3: Verify it builds

Run: pnpm type-check Expected: No errors

Step 4: Visual check

Open localhost:3000, scroll to trust strip. The 3 animated numbers should start counting up in sequence: "20+" first, "100%" ~100ms later, "10,000+" ~200ms later. The stagger should feel subtle but add polish.

Step 5: Commit

git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/components/TrustStrip.tsx
git commit -m "feat: add staggered delay to count-up animations"

Task 4: Final verification

Step 1: Run type check and lint

Run: pnpm type-check Expected: No errors

Step 2: Visual smoke test

Check localhost:3000 at:

  • Desktop (1280px): 4 stats in a row, dividers, gradient bg, count-up animation
  • Tablet (768px): same row, slightly smaller numbers
  • Mobile (375px): 2x2 grid, no dividers, animation still works

Step 3: Accessibility check

  • Tab through the page — stats section should be announced as "Company statistics"
  • Set prefers-reduced-motion: reduce in browser DevTools — numbers should show final values immediately without animation