diff --git a/apps/app/next.config.mjs b/apps/app/next.config.mjs
index dfe3d68c9..26b7ab878 100644
--- a/apps/app/next.config.mjs
+++ b/apps/app/next.config.mjs
@@ -1,5 +1,6 @@
import analyzer from '@next/bundle-analyzer';
import { getPreviewApiUrl } from '@op/core/previews';
+import { withTranspiledWorkspacesForNext } from '@op/styles/tailwind-utils';
import { withPostHogConfig } from '@posthog/nextjs-config';
import dotenv from 'dotenv';
import createNextIntlPlugin from 'next-intl/plugin';
@@ -153,12 +154,15 @@ const currentBranch = getCurrentBranch();
const allowedBranches = ['dev', 'main'];
const shouldUploadSourcemaps = allowedBranches.includes(currentBranch);
-export default withPostHogConfig(withBundleAnalyzer(withNextIntl(config)), {
- personalApiKey: process.env.POSTHOG_API_KEY,
- envId: process.env.POSTHOG_ENV_ID,
- project: 'common',
- host: 'https://eu.i.posthog.com',
- sourcemaps: {
- enabled: shouldUploadSourcemaps,
+export default withPostHogConfig(
+ withBundleAnalyzer(withNextIntl(withTranspiledWorkspacesForNext(config))),
+ {
+ personalApiKey: process.env.POSTHOG_API_KEY,
+ envId: process.env.POSTHOG_ENV_ID,
+ project: 'common',
+ host: 'https://eu.i.posthog.com',
+ sourcemaps: {
+ enabled: shouldUploadSourcemaps,
+ },
},
-});
+);
diff --git a/apps/app/package.json b/apps/app/package.json
index fec791ae5..4da39774f 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -25,7 +25,7 @@
"@op/hooks": "workspace:*",
"@op/logging": "workspace:*",
"@op/realtime": "workspace:*",
- "@op/styles": "workspace:^",
+ "@op/styles": "workspace:*",
"@op/supabase": "workspace:*",
"@op/types": "workspace:*",
"@op/ui": "workspace:*",
diff --git a/apps/app/postcss.config.mjs b/apps/app/postcss.config.mjs
index 8670f4e1b..16955e170 100644
--- a/apps/app/postcss.config.mjs
+++ b/apps/app/postcss.config.mjs
@@ -1,8 +1,3 @@
-// PostCSS config for Tailwind CSS v4
-// https://tailwindcss.com/docs/installation/using-postcss
+import { postcssConfig } from '@op/styles/postcss';
-export default {
- plugins: {
- '@tailwindcss/postcss': {},
- },
-};
+export default postcssConfig;
diff --git a/apps/app/public/coming-soon-hero.png b/apps/app/public/coming-soon-hero.png
new file mode 100644
index 000000000..61f334d3f
Binary files /dev/null and b/apps/app/public/coming-soon-hero.png differ
diff --git a/apps/app/public/logo-center-for-economic-democracy.png b/apps/app/public/logo-center-for-economic-democracy.png
new file mode 100644
index 000000000..0ade05ac5
Binary files /dev/null and b/apps/app/public/logo-center-for-economic-democracy.png differ
diff --git a/apps/app/public/logo-common.svg b/apps/app/public/logo-common.svg
new file mode 100644
index 000000000..eb7d3d5b6
--- /dev/null
+++ b/apps/app/public/logo-common.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app/public/logo-maria-fund.png b/apps/app/public/logo-maria-fund.png
new file mode 100644
index 000000000..69882dd4f
Binary files /dev/null and b/apps/app/public/logo-maria-fund.png differ
diff --git a/apps/app/public/logo-new-economy-coalition.png b/apps/app/public/logo-new-economy-coalition.png
new file mode 100644
index 000000000..dec371d51
Binary files /dev/null and b/apps/app/public/logo-new-economy-coalition.png differ
diff --git a/apps/app/public/logo-people-powered.png b/apps/app/public/logo-people-powered.png
new file mode 100644
index 000000000..f3485fbc1
Binary files /dev/null and b/apps/app/public/logo-people-powered.png differ
diff --git a/apps/app/src/app/api/waitlist-signup/route.ts b/apps/app/src/app/api/waitlist-signup/route.ts
index 90db450a9..53595cb78 100644
--- a/apps/app/src/app/api/waitlist-signup/route.ts
+++ b/apps/app/src/app/api/waitlist-signup/route.ts
@@ -11,12 +11,20 @@ mailchimpClient.setConfig({
export async function POST(req: NextRequest) {
const body = await req.json();
- const { email } = body;
+ const { firstName, lastName, email, organizationName } = body;
+
+ console.log({ firstName, lastName, email, organizationName });
if (!email) {
return Response.json({ error: 'Email is required' }, { status: 400 });
}
+ if (!firstName) {
+ return Response.json({ error: 'First name is required' }, { status: 400 });
+ }
+ if (!lastName) {
+ return Response.json({ error: 'Last name is required' }, { status: 400 });
+ }
// Generate hash from email to check if exists in Mailchimp
// If it exists, we'll update the user using `setListMember`
// That way, this endpoint doesn't fail if a user signs up twice
diff --git a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx
index 18df1b0fd..859d2419a 100644
--- a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx
+++ b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx
@@ -1,72 +1,109 @@
-import { SoftBlobs } from '@op/ui/ShaderBackground';
-import * as motion from 'motion/react-client';
+import { ButtonLink } from '@op/ui/Button';
+import { LogoLoop } from '@op/ui/LogoLoop';
+import { cn } from '@op/ui/utils';
import { WaitlistSignup } from './WaitlistSignup';
export const ComingSoonScreen = () => {
- const backgroundTransition = {
- duration: 2,
- ease: 'linear',
- delay: 1,
- };
return (
-
-
-
+
+
+
+
+
-
-
- {/* Fade top and bottom edges on mobile */}
- {/* uses -[black] because -black is renamed in our color system */}
-
-
-
-
- {/* Render the sign in link first for screenreaders */}
-
- Already have an account?{' '}
-
- Sign in
-
-
-
- Common. {' '}
-
- Connecting people, organizations, and resources to coordinate and
- grow economic democracy to global scale.
-
-
-
-
-
-
-
-
+ Log in
+
+
+
+
+
+
+ Helping people decide together how to use their resources—{' '}
+
+
+ simply, intuitively, and effectively.
+
+
+
+
+
+
+
+ Built for{' '}
+ communities ready
+ to share power and co-create{' '}
+ social change
+ —
and funders who
+ trust them to lead.
+
+
No setup headaches. No learning curve.
+
+ Common just works, instantly, for{' '}
+ everyone .
+
+
+
+
+
+ Get early access
+
+
We’re getting ready to welcome more organizations to Common.
+
Sign up now to hold your spot.
+
+
+
+
+
+ Beautifully designed
+ •
+ Easy to set up
+ •
+ No training required
+
);
};
+
+const FancyWord = ({
+ className,
+ children,
+}: {
+ className?: string;
+ children: React.ReactNode;
+}) => (
+
+ {children}
+
+);
+
+const logos = [
+ { src: '/logo-people-powered.png', alt: 'People Powered' },
+ { src: '/logo-maria-fund.png', alt: 'MariaFund' },
+ { src: '/logo-new-economy-coalition.png', alt: 'New Economy Coalition' },
+ {
+ src: '/logo-center-for-economic-democracy.png',
+ alt: 'Center for Economic Democracy',
+ },
+];
diff --git a/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx b/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx
index c955cab28..c4d78bf20 100644
--- a/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx
+++ b/apps/app/src/components/screens/ComingSoon/WaitlistSignup.tsx
@@ -1,39 +1,83 @@
'use client';
+import { Button } from '@op/ui/Button';
+import { IconButton } from '@op/ui/IconButton';
import { LoadingSpinner } from '@op/ui/LoadingSpinner';
import { toast } from '@op/ui/Toast';
import { useState } from 'react';
+import {
+ Dialog,
+ DialogTrigger,
+ Heading,
+ Modal,
+ ModalOverlay,
+} from 'react-aria-components';
+import { LuX } from 'react-icons/lu';
+import { tv } from 'tailwind-variants';
import { z } from 'zod';
import { getFieldErrorMessage, useAppForm } from '@/components/form/utils';
const validator = z.object({
+ firstName: z.string().min(1, 'Please enter your first name'),
+ lastName: z.string().min(1, 'Please enter your last name'),
email: z.email({ error: 'Please enter a valid email address' }),
+ organizationName: z.string(),
});
+const overlayStyles = tv({
+ base: 'absolute top-0 left-0 isolate z-20 h-(--page-height) w-full bg-white/[50%] text-center backdrop-blur-[3px]',
+ variants: {
+ isEntering: {
+ true: 'animate-in duration-200 ease-out fade-in',
+ },
+ isExiting: {
+ true: 'animate-out duration-200 ease-in fade-out',
+ },
+ },
+});
+
+const modalStyles = tv({
+ base: 'max-h-[calc(var(--visual-viewport-height)*.9)] w-full max-w-[min(90vw,450px)] rounded-2xl border border-black/10 bg-white bg-clip-padding text-left align-middle font-sans text-neutral-700 shadow-2xl dark:border-white/10 dark:bg-neutral-800/70 dark:text-neutral-300 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:bg-[Canvas]',
+ variants: {
+ isEntering: {
+ true: 'animate-in duration-200 ease-out zoom-in-105',
+ },
+ isExiting: {
+ true: 'animate-out duration-200 ease-in zoom-out-95',
+ },
+ },
+});
export const WaitlistSignup = () => {
const [isSubmitted, setIsSubmitted] = useState(false);
-
return (
-
-
-
- Join the waitlist
-
-
We'll email you when we launch publicly.
-
{' '}
- {isSubmitted ? (
-
- ) : (
-
setIsSubmitted(true)} />
- )}
-
+
+ Decide with us
+
+
+
+
+ {isSubmitted ? (
+
+ ) : (
+ setIsSubmitted(true)} />
+ )}
+
+
+
+
+
);
};
const WaitlistSignupForm = ({ onSuccess }: { onSuccess: () => void }) => {
const form = useAppForm({
- defaultValues: { email: '' },
+ defaultValues: {
+ firstName: '',
+ lastName: '',
+ email: '',
+ organizationName: '',
+ },
validators: {
onSubmitAsync: async ({
value,
@@ -64,53 +108,123 @@ const WaitlistSignupForm = ({ onSuccess }: { onSuccess: () => void }) => {
});
return (
- (
-
- )}
- />
+ <>
+
+
+ Common
+
+
+
+
+
+
+ Get early access. We’re getting ready to welcome more organizations to
+ Common. Sign up now to hold your spot.
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
- [formState.isSubmitting]}>
- {([isSubmitting]) => (
-
- {isSubmitting ? : 'Join waitlist'}
-
- )}
-
-
+ [formState.isSubmitting]}>
+ {([isSubmitting]) => (
+
+ {isSubmitting ? : 'Join the waitlist'}
+
+ )}
+
+
+ >
);
};
const WaitlistSignupSuccess = () => (
-
- You're signed up! We'll be in touch.
+
+ You're on the list!
+
+
+ We can’t wait to see you on Common, as an early collaborator in creating
+ an economy that works for everyone.
+
We'll be in touch soon!
);
diff --git a/package.json b/package.json
index 06d768be0..79af42acb 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@iconify/json": "2.2.311",
+ "@tailwindcss/cli": "4.1.18",
"@op/core": "workspace:*",
"@op/typescript-config": "workspace:*",
"@react-three/drei": "^10.0.4",
diff --git a/packages/styles/package.json b/packages/styles/package.json
index 956cbbffa..72b7dda7e 100644
--- a/packages/styles/package.json
+++ b/packages/styles/package.json
@@ -5,12 +5,22 @@
"license": "SEE LICENSE in /LICENSE",
"type": "module",
"exports": {
- ".": "./shared-styles.css",
+ ".": "./dist/index.css",
+ "./source": "./shared-styles.css",
"./tokens": "./tokens.css",
"./postcss": "./postcss.config.mjs",
- "./constants": "./constants.ts"
+ "./constants": "./constants.ts",
+ "./tailwind-utils": "./tailwind.utils.mjs"
+ },
+ "scripts": {
+ "build": "pnpm build:styles",
+ "build:styles": "tailwindcss -i ./styles.css -o ./dist/index.css --minify",
+ "dev": "tailwindcss -i ./styles.css -o ./dist/index.css --watch",
+ "postinstall": "tailwindcss -i ./styles.css -o ./dist/index.css",
+ "typecheck": "tsgo --noEmit"
},
"devDependencies": {
+ "@tailwindcss/cli": "4.1.18",
"@tailwindcss/postcss": "4.1.18",
"@tailwindcss/typography": "^0.5.19",
"postcss": "^8.5.3",
diff --git a/packages/styles/shared-styles.css b/packages/styles/shared-styles.css
index 23748e58d..29f3066b3 100644
--- a/packages/styles/shared-styles.css
+++ b/packages/styles/shared-styles.css
@@ -3,9 +3,6 @@
@import './tw-animate.css';
@import './intent-ui-theme.css';
-/* Tell Tailwind where to scan for classes in the UI package */
-@source '../ui/src/**/*.{js,ts,jsx,tsx}';
-
/* React Aria Components data attribute variants */
@plugin 'tailwindcss-react-aria-components';
@plugin '@tailwindcss/typography';
diff --git a/packages/styles/styles.css b/packages/styles/styles.css
new file mode 100644
index 000000000..3b328ceab
--- /dev/null
+++ b/packages/styles/styles.css
@@ -0,0 +1,7 @@
+@import './shared-styles.css';
+
+/* Scan UI package for Tailwind classes */
+@source '../ui/src/**/*.{js,ts,jsx,tsx}';
+
+/* Scan app for Tailwind classes */
+@source '../../apps/app/src/**/*.{js,ts,jsx,tsx}';
diff --git a/packages/styles/tailwind.utils.mjs b/packages/styles/tailwind.utils.mjs
new file mode 100644
index 000000000..cfe722632
--- /dev/null
+++ b/packages/styles/tailwind.utils.mjs
@@ -0,0 +1,30 @@
+const packagesUsingTailwind = {
+ '@op/ui': '../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
+};
+
+export const withUITailwindPreset = (
+ /** @type {Pick} */
+ config,
+) => {
+ return {
+ ...config,
+ content: [...config.content, ...Object.values(packagesUsingTailwind)],
+ };
+};
+
+export const withTranspiledWorkspacesForNext = (
+ /** @type {import('next').NextConfig} */
+ config,
+) => {
+ return {
+ ...config,
+ transpilePackages: [
+ ...(config.transpilePackages || []),
+ ...Object.keys(packagesUsingTailwind),
+ ],
+ experimental: {
+ ...(config.experimental || {}),
+ optimizePackageImports: Object.keys(packagesUsingTailwind),
+ },
+ };
+};
diff --git a/packages/styles/turbo.json b/packages/styles/turbo.json
new file mode 100644
index 000000000..3096478d2
--- /dev/null
+++ b/packages/styles/turbo.json
@@ -0,0 +1,12 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "outputs": ["dist/**"]
+ },
+ "dev": {
+ "cache": false,
+ "persistent": true
+ }
+ }
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4a783c591..60da11616 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -26,6 +26,7 @@
"./Header": "./src/components/Header.tsx",
"./HorizontalList": "./src/components/HorizontalList/index.tsx",
"./IconButton": "./src/components/IconButton.tsx",
+ "./LogoLoop": "./src/components/LogoLoop.tsx",
"./Menu": "./src/components/Menu.tsx",
"./Modal": "./src/components/Modal.tsx",
"./MultiSelectComboBox": "./src/components/MultiSelectComboBox.tsx",
diff --git a/packages/ui/src/components/LogoLoop.tsx b/packages/ui/src/components/LogoLoop.tsx
new file mode 100644
index 000000000..5917c9826
--- /dev/null
+++ b/packages/ui/src/components/LogoLoop.tsx
@@ -0,0 +1,551 @@
+// From https://reactbits.dev/animations/logo-loop
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+export type LogoItem =
+ | {
+ node: React.ReactNode;
+ href?: string;
+ title?: string;
+ ariaLabel?: string;
+ }
+ | {
+ src: string;
+ alt?: string;
+ href?: string;
+ title?: string;
+ srcSet?: string;
+ sizes?: string;
+ width?: number;
+ height?: number;
+ };
+
+export interface LogoLoopProps {
+ logos: LogoItem[];
+ speed?: number;
+ direction?: 'left' | 'right' | 'up' | 'down';
+ width?: number | string;
+ logoHeight?: number;
+ gap?: number;
+ pauseOnHover?: boolean;
+ hoverSpeed?: number;
+ fadeOut?: boolean;
+ fadeOutColor?: string;
+ scaleOnHover?: boolean;
+ renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode;
+ ariaLabel?: string;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const ANIMATION_CONFIG = {
+ SMOOTH_TAU: 0.25,
+ MIN_COPIES: 2,
+ COPY_HEADROOM: 2,
+} as const;
+
+const toCssLength = (value?: number | string): string | undefined =>
+ typeof value === 'number' ? `${value}px` : (value ?? undefined);
+
+const cx = (...parts: Array) =>
+ parts.filter(Boolean).join(' ');
+
+const useResizeObserver = (
+ callback: () => void,
+ elements: Array>,
+ dependencies: React.DependencyList,
+) => {
+ useEffect(() => {
+ if (!window.ResizeObserver) {
+ const handleResize = () => callback();
+ window.addEventListener('resize', handleResize);
+ callback();
+ return () => window.removeEventListener('resize', handleResize);
+ }
+
+ const observers = elements.map((ref) => {
+ if (!ref.current) return null;
+ const observer = new ResizeObserver(callback);
+ observer.observe(ref.current);
+ return observer;
+ });
+
+ callback();
+
+ return () => {
+ observers.forEach((observer) => observer?.disconnect());
+ };
+ }, dependencies);
+};
+
+const useImageLoader = (
+ seqRef: React.RefObject,
+ onLoad: () => void,
+ dependencies: React.DependencyList,
+) => {
+ useEffect(() => {
+ const images = seqRef.current?.querySelectorAll('img') ?? [];
+
+ if (images.length === 0) {
+ onLoad();
+ return;
+ }
+
+ let remainingImages = images.length;
+ const handleImageLoad = () => {
+ remainingImages -= 1;
+ if (remainingImages === 0) {
+ onLoad();
+ }
+ };
+
+ images.forEach((img) => {
+ const htmlImg = img as HTMLImageElement;
+ if (htmlImg.complete) {
+ handleImageLoad();
+ } else {
+ htmlImg.addEventListener('load', handleImageLoad, { once: true });
+ htmlImg.addEventListener('error', handleImageLoad, { once: true });
+ }
+ });
+
+ return () => {
+ images.forEach((img) => {
+ img.removeEventListener('load', handleImageLoad);
+ img.removeEventListener('error', handleImageLoad);
+ });
+ };
+ }, dependencies);
+};
+
+const useAnimationLoop = (
+ trackRef: React.RefObject,
+ targetVelocity: number,
+ seqWidth: number,
+ seqHeight: number,
+ isHovered: boolean,
+ hoverSpeed: number | undefined,
+ isVertical: boolean,
+) => {
+ const rafRef = useRef(null);
+ const lastTimestampRef = useRef(null);
+ const offsetRef = useRef(0);
+ const velocityRef = useRef(0);
+
+ useEffect(() => {
+ const track = trackRef.current;
+ if (!track) return;
+
+ const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ const seqSize = isVertical ? seqHeight : seqWidth;
+
+ if (seqSize > 0) {
+ offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
+ const transformValue = isVertical
+ ? `translate3d(0, ${-offsetRef.current}px, 0)`
+ : `translate3d(${-offsetRef.current}px, 0, 0)`;
+ track.style.transform = transformValue;
+ }
+
+ if (prefersReduced) {
+ track.style.transform = isVertical
+ ? 'translate3d(0, 0, 0)'
+ : 'translate3d(0, 0, 0)';
+ return () => {
+ lastTimestampRef.current = null;
+ };
+ }
+
+ const animate = (timestamp: number) => {
+ if (lastTimestampRef.current === null) {
+ lastTimestampRef.current = timestamp;
+ }
+
+ const deltaTime =
+ Math.max(0, timestamp - lastTimestampRef.current) / 1000;
+ lastTimestampRef.current = timestamp;
+
+ const target =
+ isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
+
+ const easingFactor =
+ 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
+ velocityRef.current += (target - velocityRef.current) * easingFactor;
+
+ if (seqSize > 0) {
+ let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
+ nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
+ offsetRef.current = nextOffset;
+
+ const transformValue = isVertical
+ ? `translate3d(0, ${-offsetRef.current}px, 0)`
+ : `translate3d(${-offsetRef.current}px, 0, 0)`;
+ track.style.transform = transformValue;
+ }
+
+ rafRef.current = requestAnimationFrame(animate);
+ };
+
+ rafRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ lastTimestampRef.current = null;
+ };
+ }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
+};
+
+export const LogoLoop = React.memo(
+ ({
+ logos,
+ speed = 120,
+ direction = 'left',
+ width = '100%',
+ logoHeight = 28,
+ gap = 32,
+ pauseOnHover,
+ hoverSpeed,
+ fadeOut = false,
+ fadeOutColor,
+ scaleOnHover = false,
+ renderItem,
+ ariaLabel = 'Partner logos',
+ className,
+ style,
+ }) => {
+ const containerRef = useRef(null);
+ const trackRef = useRef(null);
+ const seqRef = useRef(null);
+
+ const [seqWidth, setSeqWidth] = useState(0);
+ const [seqHeight, setSeqHeight] = useState(0);
+ const [copyCount, setCopyCount] = useState(
+ ANIMATION_CONFIG.MIN_COPIES,
+ );
+ const [isHovered, setIsHovered] = useState(false);
+
+ const effectiveHoverSpeed = useMemo(() => {
+ if (hoverSpeed !== undefined) return hoverSpeed;
+ if (pauseOnHover === true) return 0;
+ if (pauseOnHover === false) return undefined;
+ return 0;
+ }, [hoverSpeed, pauseOnHover]);
+
+ const isVertical = direction === 'up' || direction === 'down';
+
+ const targetVelocity = useMemo(() => {
+ const magnitude = Math.abs(speed);
+ let directionMultiplier: number;
+ if (isVertical) {
+ directionMultiplier = direction === 'up' ? 1 : -1;
+ } else {
+ directionMultiplier = direction === 'left' ? 1 : -1;
+ }
+ const speedMultiplier = speed < 0 ? -1 : 1;
+ return magnitude * directionMultiplier * speedMultiplier;
+ }, [speed, direction, isVertical]);
+
+ const updateDimensions = useCallback(() => {
+ const containerWidth = containerRef.current?.clientWidth ?? 0;
+ const sequenceRect = seqRef.current?.getBoundingClientRect?.();
+ const sequenceWidth = sequenceRect?.width ?? 0;
+ const sequenceHeight = sequenceRect?.height ?? 0;
+ if (isVertical) {
+ const parentHeight =
+ containerRef.current?.parentElement?.clientHeight ?? 0;
+ if (containerRef.current && parentHeight > 0) {
+ const targetHeight = Math.ceil(parentHeight);
+ if (containerRef.current.style.height !== `${targetHeight}px`)
+ containerRef.current.style.height = `${targetHeight}px`;
+ }
+ if (sequenceHeight > 0) {
+ setSeqHeight(Math.ceil(sequenceHeight));
+ const viewport =
+ containerRef.current?.clientHeight ??
+ parentHeight ??
+ sequenceHeight;
+ const copiesNeeded =
+ Math.ceil(viewport / sequenceHeight) +
+ ANIMATION_CONFIG.COPY_HEADROOM;
+ setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
+ }
+ } else if (sequenceWidth > 0) {
+ setSeqWidth(Math.ceil(sequenceWidth));
+ const copiesNeeded =
+ Math.ceil(containerWidth / sequenceWidth) +
+ ANIMATION_CONFIG.COPY_HEADROOM;
+ setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
+ }
+ }, [isVertical]);
+
+ useResizeObserver(
+ updateDimensions,
+ [containerRef, seqRef],
+ [logos, gap, logoHeight, isVertical],
+ );
+
+ useImageLoader(seqRef, updateDimensions, [
+ logos,
+ gap,
+ logoHeight,
+ isVertical,
+ ]);
+
+ useAnimationLoop(
+ trackRef,
+ targetVelocity,
+ seqWidth,
+ seqHeight,
+ isHovered,
+ effectiveHoverSpeed,
+ isVertical,
+ );
+
+ const cssVariables = useMemo(
+ () =>
+ ({
+ '--logoloop-gap': `${gap}px`,
+ '--logoloop-logoHeight': `${logoHeight}px`,
+ ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
+ }) as React.CSSProperties,
+ [gap, logoHeight, fadeOutColor],
+ );
+
+ const rootClasses = useMemo(
+ () =>
+ cx(
+ 'relative group',
+ isVertical
+ ? 'overflow-hidden h-full inline-block'
+ : 'overflow-x-hidden',
+ '[--logoloop-gap:32px]',
+ '[--logoloop-logoHeight:28px]',
+ '[--logoloop-fadeColorAuto:#ffffff]',
+ 'dark:[--logoloop-fadeColorAuto:#0b0b0b]',
+ scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]',
+ className,
+ ),
+ [isVertical, scaleOnHover, className],
+ );
+
+ const handleMouseEnter = useCallback(() => {
+ if (effectiveHoverSpeed !== undefined) setIsHovered(true);
+ }, [effectiveHoverSpeed]);
+ const handleMouseLeave = useCallback(() => {
+ if (effectiveHoverSpeed !== undefined) setIsHovered(false);
+ }, [effectiveHoverSpeed]);
+
+ const renderLogoItem = useCallback(
+ (item: LogoItem, key: React.Key) => {
+ if (renderItem) {
+ return (
+
+ {renderItem(item, key)}
+
+ );
+ }
+
+ const isNodeItem = 'node' in item;
+
+ const content = isNodeItem ? (
+
+ {(item as any).node}
+
+ ) : (
+
+ );
+
+ const itemAriaLabel = isNodeItem
+ ? ((item as any).ariaLabel ?? (item as any).title)
+ : ((item as any).alt ?? (item as any).title);
+
+ const inner = (item as any).href ? (
+
+ {content}
+
+ ) : (
+ content
+ );
+
+ return (
+
+ {inner}
+
+ );
+ },
+ [isVertical, scaleOnHover, renderItem],
+ );
+
+ const logoLists = useMemo(
+ () =>
+ Array.from({ length: copyCount }, (_, copyIndex) => (
+ 0}
+ ref={copyIndex === 0 ? seqRef : undefined}
+ >
+ {logos.map((item, itemIndex) =>
+ renderLogoItem(item, `${copyIndex}-${itemIndex}`),
+ )}
+
+ )),
+ [copyCount, logos, renderLogoItem, isVertical],
+ );
+
+ const containerStyle = useMemo(
+ (): React.CSSProperties => ({
+ width: isVertical
+ ? toCssLength(width) === '100%'
+ ? undefined
+ : toCssLength(width)
+ : (toCssLength(width) ?? '100%'),
+ ...cssVariables,
+ ...style,
+ }),
+ [width, cssVariables, style, isVertical],
+ );
+
+ return (
+
+ {fadeOut && (
+ <>
+ {isVertical ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+
+
+ {logoLists}
+
+
+ );
+ },
+);
+
+LogoLoop.displayName = 'LogoLoop';
+
+export default LogoLoop;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eaaa4c7e2..983ee288f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -103,6 +103,9 @@ importers:
'@supabase/supabase-js':
specifier: ^2.49.3
version: 2.49.3
+ '@tailwindcss/cli':
+ specifier: 4.1.18
+ version: 4.1.18
'@tanstack/query-sync-storage-persister':
specifier: 5.66.11
version: 5.66.11
@@ -390,7 +393,7 @@ importers:
specifier: workspace:*
version: link:../../services/realtime
'@op/styles':
- specifier: workspace:^
+ specifier: workspace:*
version: link:../../packages/styles
'@op/supabase':
specifier: workspace:*
@@ -790,6 +793,9 @@ importers:
packages/styles:
devDependencies:
+ '@tailwindcss/cli':
+ specifier: 4.1.18
+ version: 4.1.18
'@tailwindcss/postcss':
specifier: 4.1.18
version: 4.1.18
@@ -4375,6 +4381,10 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+ '@tailwindcss/cli@4.1.18':
+ resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==}
+ hasBin: true
+
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
@@ -11303,7 +11313,6 @@ snapshots:
'@parcel/watcher-win32-arm64': 2.5.1
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
- optional: true
'@petamoriken/float16@3.9.3':
optional: true
@@ -13047,6 +13056,16 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@tailwindcss/cli@4.1.18':
+ dependencies:
+ '@parcel/watcher': 2.5.1
+ '@tailwindcss/node': 4.1.18
+ '@tailwindcss/oxide': 4.1.18
+ enhanced-resolve: 5.18.4
+ mri: 1.2.0
+ picocolors: 1.1.1
+ tailwindcss: 4.1.18
+
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -14676,8 +14695,7 @@ snapshots:
detect-indent@7.0.1: {}
- detect-libc@1.0.3:
- optional: true
+ detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
@@ -16532,8 +16550,7 @@ snapshots:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
- mri@1.2.0:
- optional: true
+ mri@1.2.0: {}
mrmime@2.0.1: {}
@@ -16640,8 +16657,7 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
- node-addon-api@7.1.1:
- optional: true
+ node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
diff --git a/turbo.json b/turbo.json
index ffbbf2ea8..533c76f15 100644
--- a/turbo.json
+++ b/turbo.json
@@ -8,7 +8,8 @@
".next/**",
"!.next/cache/**",
"storybook-static/**",
- "build/**"
+ "build/**",
+ "dist/**"
]
},
"migrate": {