Skip to content

Commit 21fd838

Browse files
[Website] Cycle through customers (#10030)
Co-authored-by: Maxime Preaux <hello@maxime.dev>
1 parent 495b867 commit 21fd838

40 files changed

Lines changed: 1569 additions & 35 deletions

website/app/globals.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,33 @@ svg[role="graphics-document"][id^="mermaid-"] {
527527
display: none !important;
528528
}
529529
}
530+
531+
@keyframes logo-in {
532+
from {
533+
opacity: 0;
534+
transform: translateY(50%);
535+
}
536+
to {
537+
opacity: 1;
538+
transform: translateY(0);
539+
}
540+
}
541+
542+
@keyframes logo-out {
543+
from {
544+
opacity: 1;
545+
transform: translateY(0);
546+
}
547+
to {
548+
opacity: 0;
549+
transform: translateY(-50%);
550+
}
551+
}
552+
553+
.animate-logo-in {
554+
animation: logo-in 900ms cubic-bezier(1, 0, 0, 1) both;
555+
}
556+
557+
.animate-logo-out {
558+
animation: logo-out 900ms cubic-bezier(1, 0, 0, 1) both;
559+
}
Lines changed: 150 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,160 @@
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+
1120
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+
1259
return (
1360
<section className="mx-auto max-w-7xl px-5 py-12 text-center sm:px-12 sm:py-16">
1461
<p className="text-cc-ink-dim font-mono text-xs tracking-[0.2em] uppercase">
1562
Trusted by Enterprises
1663
</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+
))}
4272
</div>
4373
</section>
4474
);
4575
}
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

Comments
 (0)