|
1 | | -import { GalaxusLogo } from "@/src/icons/GalaxusLogo"; |
2 | | -import { MicrosoftLogo } from "@/src/icons/MicrosoftLogo"; |
3 | | -import { SwissLifeLogo } from "@/src/icons/SwissLifeLogo"; |
4 | | - |
5 | | -/** |
6 | | - * Social-proof logo cloud beneath the hero: a small uppercase label over a row |
7 | | - * of customer wordmarks. The logos are inlined single-path SVGs that inherit |
8 | | - * the near-white heading color via `currentColor`, so they read as a calm, |
9 | | - * monochrome band rather than competing brand colors. |
10 | | - */ |
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useRef, useState } from "react"; |
| 4 | +import { FEATURED_COMPANIES, OTHER_COMPANIES, type Company } from "./companies"; |
| 5 | + |
| 6 | +const ROTATE_INTERVAL_MS = 4000; |
| 7 | +const INITIAL_HOLD_MS = 3500; |
| 8 | +const ITEM_ANIMATION_OFFSET_MS = 250; |
| 9 | + |
| 10 | +function wrapIndex(index: number, length: number) { |
| 11 | + return ((index % length) + length) % length; |
| 12 | +} |
| 13 | + |
| 14 | +function getItem<T>(index: number, array: T[]) { |
| 15 | + return array[wrapIndex(index, array.length)]; |
| 16 | +} |
| 17 | + |
| 18 | +const NUM_SLOTS = FEATURED_COMPANIES.length; |
| 19 | + |
11 | 20 | export function LogoCloud() { |
| 21 | + const [companyQueue] = useState(() => [ |
| 22 | + ...shuffle(OTHER_COMPANIES), |
| 23 | + ...FEATURED_COMPANIES, |
| 24 | + ]); |
| 25 | + const currentCompanyIndex = useRef(0); |
| 26 | + const [slots, setSlots] = useState<Company[]>(() => [...FEATURED_COMPANIES]); |
| 27 | + |
| 28 | + useEffect(() => { |
| 29 | + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| 30 | + return; |
| 31 | + } |
| 32 | + |
| 33 | + const swap = () => { |
| 34 | + const index = currentCompanyIndex.current; |
| 35 | + const updatedSlots = new Array(NUM_SLOTS) |
| 36 | + .fill(0) |
| 37 | + .map((_, i) => getItem(index + i, companyQueue)); |
| 38 | + setSlots(updatedSlots); |
| 39 | + |
| 40 | + currentCompanyIndex.current = wrapIndex( |
| 41 | + currentCompanyIndex.current + NUM_SLOTS, |
| 42 | + companyQueue.length, |
| 43 | + ); |
| 44 | + }; |
| 45 | + |
| 46 | + // Hold the featured three, then rotate at a steady cadence. |
| 47 | + let intervalId = 0; |
| 48 | + const startId = window.setTimeout(() => { |
| 49 | + swap(); |
| 50 | + intervalId = window.setInterval(swap, ROTATE_INTERVAL_MS); |
| 51 | + }, INITIAL_HOLD_MS); |
| 52 | + |
| 53 | + return () => { |
| 54 | + window.clearTimeout(startId); |
| 55 | + window.clearInterval(intervalId); |
| 56 | + }; |
| 57 | + }, [companyQueue]); |
| 58 | + |
12 | 59 | return ( |
13 | 60 | <section className="mx-auto max-w-7xl px-5 py-12 text-center sm:px-12 sm:py-16"> |
14 | 61 | <p className="text-cc-ink-dim font-mono text-xs tracking-[0.2em] uppercase"> |
15 | 62 | Trusted by Enterprises |
16 | 63 | </p> |
17 | | - <div className="text-cc-heading mt-10 flex flex-wrap items-center justify-center gap-x-16 gap-y-10 sm:mt-14 sm:gap-x-24 lg:grid lg:grid-cols-3 lg:place-items-center lg:gap-x-8"> |
18 | | - <a |
19 | | - href="https://www.galaxus.ch" |
20 | | - target="_blank" |
21 | | - rel="noopener noreferrer" |
22 | | - aria-label="Galaxus" |
23 | | - > |
24 | | - <GalaxusLogo className="h-9 w-auto sm:h-11" /> |
25 | | - </a> |
26 | | - <a |
27 | | - href="https://www.swisslife.ch" |
28 | | - target="_blank" |
29 | | - rel="noopener noreferrer" |
30 | | - aria-label="Swiss Life" |
31 | | - > |
32 | | - <SwissLifeLogo className="h-18 w-auto sm:h-22" /> |
33 | | - </a> |
34 | | - <a |
35 | | - href="https://www.microsoft.com" |
36 | | - target="_blank" |
37 | | - rel="noopener noreferrer" |
38 | | - aria-label="Microsoft" |
39 | | - > |
40 | | - <MicrosoftLogo className="h-9 w-auto sm:h-11" /> |
41 | | - </a> |
| 64 | + <div className="text-cc-heading mt-10 grid grid-cols-1 place-items-center gap-y-10 sm:mt-14 sm:grid-cols-3 sm:gap-x-8"> |
| 65 | + {slots.map((company, index) => ( |
| 66 | + <LogoSlot |
| 67 | + key={index} |
| 68 | + company={company} |
| 69 | + delay={index * ITEM_ANIMATION_OFFSET_MS} |
| 70 | + /> |
| 71 | + ))} |
42 | 72 | </div> |
43 | 73 | </section> |
44 | 74 | ); |
45 | 75 | } |
| 76 | + |
| 77 | +function LogoSlot({ company, delay }: { company: Company; delay: number }) { |
| 78 | + const [displayed, setDisplayed] = useState(company); |
| 79 | + const [previous, setPrevious] = useState<Company | null>(null); |
| 80 | + const prevCompanyRef = useRef(company); |
| 81 | + |
| 82 | + // Promote the outgoing logo to `previous` (it plays the fade-out) and show |
| 83 | + // the new one. The in-animation only runs when there was a previous logo, |
| 84 | + // so the initial server-rendered three appear without a flash. |
| 85 | + useEffect(() => { |
| 86 | + if (prevCompanyRef.current.name === company.name) { |
| 87 | + return; |
| 88 | + } |
| 89 | + |
| 90 | + setPrevious(prevCompanyRef.current); |
| 91 | + setDisplayed(company); |
| 92 | + prevCompanyRef.current = company; |
| 93 | + }, [company]); |
| 94 | + |
| 95 | + return ( |
| 96 | + <div className="relative flex h-20 w-full items-center justify-center overflow-hidden"> |
| 97 | + {previous && ( |
| 98 | + <CompanyLink |
| 99 | + key={previous.name} |
| 100 | + company={previous} |
| 101 | + animationClassName="animate-logo-out" |
| 102 | + onAnimationEnd={() => setPrevious(null)} |
| 103 | + hidden |
| 104 | + delay={delay} |
| 105 | + /> |
| 106 | + )} |
| 107 | + <CompanyLink |
| 108 | + key={displayed.name} |
| 109 | + company={displayed} |
| 110 | + animationClassName={previous ? "animate-logo-in" : undefined} |
| 111 | + delay={delay} |
| 112 | + /> |
| 113 | + </div> |
| 114 | + ); |
| 115 | +} |
| 116 | + |
| 117 | +function CompanyLink({ |
| 118 | + company, |
| 119 | + animationClassName, |
| 120 | + onAnimationEnd, |
| 121 | + hidden = false, |
| 122 | + delay, |
| 123 | +}: { |
| 124 | + company: Company; |
| 125 | + animationClassName?: string; |
| 126 | + onAnimationEnd?: () => void; |
| 127 | + hidden?: boolean; |
| 128 | + delay: number; |
| 129 | +}) { |
| 130 | + const { Logo } = company; |
| 131 | + return ( |
| 132 | + <a |
| 133 | + href={company.href} |
| 134 | + target="_blank" |
| 135 | + rel="noopener noreferrer" |
| 136 | + aria-label={company.name} |
| 137 | + aria-hidden={hidden} |
| 138 | + tabIndex={hidden ? -1 : undefined} |
| 139 | + onAnimationEnd={onAnimationEnd} |
| 140 | + className={`absolute inset-0 flex items-center justify-center transition-opacity ${ |
| 141 | + animationClassName ?? "" |
| 142 | + }`} |
| 143 | + style={{ animationDelay: delay + "ms" }} |
| 144 | + > |
| 145 | + <Logo |
| 146 | + className={`max-w-full ${company.maxHeightClassName ?? "max-h-11"}`} |
| 147 | + style={{ width: `${company.width}px` }} |
| 148 | + /> |
| 149 | + </a> |
| 150 | + ); |
| 151 | +} |
| 152 | + |
| 153 | +function shuffle<T>(items: readonly T[]): T[] { |
| 154 | + const result = [...items]; |
| 155 | + for (let i = result.length - 1; i > 0; i--) { |
| 156 | + const j = Math.floor(Math.random() * (i + 1)); |
| 157 | + [result[i], result[j]] = [result[j], result[i]]; |
| 158 | + } |
| 159 | + return result; |
| 160 | +} |
0 commit comments