This commit is contained in:
Mackie 2026-05-24 14:49:40 +08:00
parent 42b158a6ed
commit b16926d1bb
9 changed files with 578 additions and 30 deletions

View file

@ -42,6 +42,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"framer-motion": "^12.40.0",
"geist": "^1.3.0", "geist": "^1.3.0",
"graphql": "^16.8.2", "graphql": "^16.8.2",
"lucide-react": "0.563.0", "lucide-react": "0.563.0",
@ -52,6 +53,7 @@
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"react-hook-form": "7.71.1", "react-hook-form": "7.71.1",
"react-use-measure": "^2.1.7",
"sharp": "0.34.2", "sharp": "0.34.2",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },

56
pnpm-lock.yaml generated
View file

@ -68,6 +68,9 @@ importers:
dotenv: dotenv:
specifier: 16.4.7 specifier: 16.4.7
version: 16.4.7 version: 16.4.7
framer-motion:
specifier: ^12.40.0
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
geist: geist:
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.7.1(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4)) version: 1.7.1(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))
@ -98,6 +101,9 @@ importers:
react-hook-form: react-hook-form:
specifier: 7.71.1 specifier: 7.71.1
version: 7.71.1(react@19.2.6) version: 7.71.1(react@19.2.6)
react-use-measure:
specifier: ^2.1.7
version: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
sharp: sharp:
specifier: 0.34.2 specifier: 0.34.2
version: 0.34.2 version: 0.34.2
@ -3057,6 +3063,20 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.40.0:
resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.2: fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -3809,6 +3829,12 @@ packages:
resolution: {integrity: sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==} resolution: {integrity: sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==}
engines: {node: '>=16.20.1'} engines: {node: '>=16.20.1'}
motion-dom@12.40.0:
resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
mpath@0.8.4: mpath@0.8.4:
resolution: {integrity: sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==} resolution: {integrity: sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@ -4183,6 +4209,15 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=16.6.0' react-dom: '>=16.6.0'
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
react: '>=16.13'
react-dom: '>=16.13'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.6: react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -8081,6 +8116,15 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
motion-dom: 12.40.0
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
fsevents@2.3.2: fsevents@2.3.2:
optional: true optional: true
@ -8935,6 +8979,12 @@ snapshots:
- socks - socks
- supports-color - supports-color
motion-dom@12.40.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
mpath@0.8.4: {} mpath@0.8.4: {}
mpath@0.9.0: {} mpath@0.9.0: {}
@ -9362,6 +9412,12 @@ snapshots:
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
react-use-measure@2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
optionalDependencies:
react-dom: 19.2.6(react@19.2.6)
react@19.2.6: {} react@19.2.6: {}
readable-stream@3.6.2: readable-stream@3.6.2:

View file

@ -1,52 +1,56 @@
import { cn } from '@/utilities/ui' import * as React from "react"
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cva } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import { cn } from "@/utilities/ui"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground', "bg-secondary text-secondary-foreground hover:bg-secondary/80",
link: 'text-primary underline-offset-4 hover:underline', ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
clear: '', default: "h-10 px-4 py-2",
default: 'h-10 px-4 py-2 has-[>svg]:px-3', sm: "h-9 rounded-md px-3",
sm: 'h-9 rounded-md px-3 has-[>svg]:px-2.5', lg: "h-11 rounded-md px-8",
lg: 'h-11 rounded-md px-8 has-[>svg]:px-4', icon: "h-10 w-10",
icon: 'size-10',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) )
export interface ButtonProps export interface ButtonProps
extends React.ComponentProps<'button'>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }
const Button: React.FC<ButtonProps> = ({ asChild = false, className, size, variant, ...props }) => { const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : 'button' ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} {...props}
/> />
) )
} },
)
Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

View file

@ -0,0 +1,134 @@
'use client';
import React from 'react';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/utilities/ui';
import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon';
import { useScroll } from '@/components/ui/use-scroll';
import { createPortal } from 'react-dom';
export function Header() {
const [open, setOpen] = React.useState(false);
const scrolled = useScroll(10);
const links = [
{
label: 'Features',
href: '#',
},
{
label: 'Pricing',
href: '#',
},
{
label: 'About',
href: '#',
},
];
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<header
className={cn('sticky top-0 z-50 w-full border-b border-transparent', {
'bg-background/95 supports-[backdrop-filter]:bg-background/50 border-border backdrop-blur-lg':
scrolled,
})}
>
<nav className="mx-auto flex h-14 w-full max-w-5xl items-center justify-between px-4">
<div className="hover:bg-accent rounded-md p-2">
<WordmarkIcon className="h-4" />
</div>
<div className="hidden items-center gap-2 md:flex">
{links.map((link) => (
<a key={link.label} className={buttonVariants({ variant: 'ghost' })} href={link.href}>
{link.label}
</a>
))}
<Button variant="outline">Sign In</Button>
<Button>Get Started</Button>
</div>
<Button
size="icon"
variant="outline"
onClick={() => setOpen(!open)}
className="md:hidden"
aria-expanded={open}
aria-controls="mobile-menu"
aria-label="Toggle menu"
>
<MenuToggleIcon open={open} className="size-5" duration={300} />
</Button>
</nav>
<MobileMenu open={open} className="flex flex-col justify-between gap-2">
<div className="grid gap-y-2">
{links.map((link) => (
<a
key={link.label}
className={buttonVariants({
variant: 'ghost',
className: 'justify-start',
})}
href={link.href}
>
{link.label}
</a>
))}
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full bg-transparent">
Sign In
</Button>
<Button className="w-full">Get Started</Button>
</div>
</MobileMenu>
</header>
);
}
type MobileMenuProps = React.ComponentProps<'div'> & {
open: boolean;
};
function MobileMenu({ open, children, className, ...props }: MobileMenuProps) {
if (!open || typeof window === 'undefined') return null;
return createPortal(
<div
id="mobile-menu"
className={cn(
'bg-background/95 supports-[backdrop-filter]:bg-background/50 backdrop-blur-lg',
'fixed top-14 right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden border-y md:hidden',
)}
>
<div
data-slot={open ? 'open' : 'closed'}
className={cn(
'data-[slot=open]:animate-in data-[slot=open]:zoom-in-97 ease-out',
'size-full p-4',
className,
)}
{...props}
>
{children}
</div>
</div>,
document.body,
);
}
export const WordmarkIcon = (props: React.ComponentProps<"svg">) => (
<svg viewBox="0 0 84 24" fill="currentColor" {...props}>
<path d="M45.035 23.984c-1.34-.062-2.566-.441-3.777-1.16-1.938-1.152-3.465-3.187-4.02-5.36-.199-.784-.238-1.128-.234-2.058 0-.691.008-.87.062-1.207.23-1.5.852-2.883 1.852-4.144.297-.371 1.023-1.09 1.41-1.387 1.399-1.082 2.84-1.68 4.406-1.816.536-.047 1.528-.02 2.047.054 1.227.184 2.227.543 3.106 1.121 1.277.84 2.5 2.184 3.367 3.7.098.168.172.308.172.312-.004 0-1.047.723-2.32 1.598l-2.711 1.867c-.61.422-2.91 2.008-2.993 2.062l-.074.047-1-1.574c-.55-.867-1.008-1.594-1.012-1.61-.007-.019.922-.648 2.188-1.476 1.215-.793 2.2-1.453 2.191-1.46-.02-.032-.508-.27-.691-.34a5 5 0 0 0-.465-.13c-.371-.09-1.105-.125-1.426-.07-1.285.219-2.336 1.3-2.777 2.852-.215.761-.242 1.636-.074 2.355.129.527.383 1.102.691 1.543.234.332.727.82 1.047 1.031.664.434 1.195.586 1.969.555.613-.023 1.027-.129 1.64-.426 1.184-.574 2.16-1.554 2.828-2.843.122-.235.208-.372.227-.368.082.032 3.77 1.938 3.79 1.961.034.032-.407.93-.696 1.414a12 12 0 0 1-1.051 1.477c-.36.422-1.102 1.14-1.492 1.445a9.9 9.9 0 0 1-3.23 1.684 9.2 9.2 0 0 1-2.95.351M74.441 23.996c-1.488-.043-2.8-.363-4.066-.992-1.687-.848-2.992-2.14-3.793-3.774-.605-1.234-.863-2.402-.863-3.894.004-1.149.176-2.156.527-3.11.14-.378.531-1.171.75-1.515 1.078-1.703 2.758-2.934 4.805-3.524.847-.242 1.465-.332 2.433-.351 1.032-.024 1.743.055 2.48.277l.31.09.007 2.48c.004 1.364 0 2.481-.008 2.481a1 1 0 0 1-.12-.055c-.688-.347-2.09-.488-2.962-.296-.754.167-1.296.453-1.785.945a3.7 3.7 0 0 0-1.043 2.11c-.047.382-.02 1.109.055 1.437a3.4 3.4 0 0 0 .941 1.738c.75.75 1.715 1.102 2.875 1.05.645-.03 1.118-.14 1.563-.366q1.721-.864 2.02-3.145c.035-.293.042-1.266.042-7.957V0H84l-.012 8.434c-.008 7.851-.011 8.457-.054 8.757-.196 1.274-.586 2.25-1.301 3.243-1.293 1.808-3.555 3.07-6.145 3.437-.664.098-1.43.14-2.047.125M9.848 23.574a14 14 0 0 1-1.137-.152c-2.352-.426-4.555-1.781-6.117-3.774-.27-.335-.75-1.05-.95-1.406-1.156-2.047-1.695-4.27-1.64-6.77.047-1.995.43-3.66 1.23-5.316.524-1.086 1.04-1.87 1.793-2.715C4.567 1.72 6.652.535 8.793.171 9.68.02 10.093 0 12.297 0h1.789v5.441l-.961.016c-2.36.04-3.441.215-4.441.719-.836.414-1.278.879-1.895 1.976-.219.399-.535 1.02-.535 1.063 0 .02 1.285.027 3.918.027h3.914v5.113h-3.914c-2.54 0-3.918.008-3.918.028 0 .05.254.597.441.953.344.656.649 1.086 1.051 1.48.668.657 1.356.985 2.445 1.16.645.106 1.274.145 2.61.16l1.285.016v5.442l-2.055-.004a120 120 0 0 1-2.183-.016M16.469 14.715c0-5.504.011-9.04.031-9.29a5.54 5.54 0 0 1 1.527-3.48c.778-.82 1.922-1.457 3.118-1.734C21.915.035 22.422 0 24.39 0h1.652v4.914h-1.426c-1.324 0-1.445.004-1.644.055-.739.191-1.059.699-1.106 1.754l-.015.355h4.191v4.914h-4.184v11.602h-5.39ZM27.023 14.727c0-5.223.012-9.04.028-9.278.129-1.98 1.234-3.68 3.012-4.62.87-.462 1.777-.716 2.851-.802A61 61 0 0 1 34.945 0h1.649v4.914h-1.426c-1.32 0-1.441.004-1.64.055-.739.191-1.063.699-1.106 1.754l-.02.355h4.192v4.914H32.41v11.602h-5.387ZM55.48 15.406V7.22h4.66v1.363c0 1.3.005 1.363.051 1.363.04 0 .075-.054.133-.203.38-.98.969-1.68 1.711-2.031.563-.266 1.422-.43 2.492-.48l.414-.02v4.914l-.414.035c-.738.063-1.597.195-2.058.313-.297.082-.688.28-.875.449-.324.289-.532.703-.625 1.254-.094.547-.098.879-.098 5.144v4.274h-5.39Zm0 0" />
</svg>
);

View file

@ -0,0 +1,130 @@
import { cn } from "@/utilities/ui";
import { Button } from "@/components/ui/button";
import { RocketIcon, ArrowRightIcon, PhoneCallIcon } from "lucide-react";
import { LogoCloud } from "@/components/ui/logo-cloud-3";
export function HeroSection() {
return (
<section className="mx-auto w-full max-w-5xl">
{/* Top Shades */}
<div
aria-hidden="true"
className="absolute inset-0 isolate hidden overflow-hidden contain-strict lg:block"
>
<div className="absolute inset-0 -top-14 isolate -z-10 bg-[radial-gradient(35%_80%_at_49%_0%,--theme(--color-foreground/.08),transparent)] contain-strict" />
</div>
{/* X Bold Faded Borders */}
<div
aria-hidden="true"
className="absolute inset-0 mx-auto hidden min-h-screen w-full max-w-5xl lg:block"
>
<div className="mask-y-from-80% mask-y-to-100% absolute inset-y-0 left-0 z-10 h-full w-px bg-foreground/15" />
<div className="mask-y-from-80% mask-y-to-100% absolute inset-y-0 right-0 z-10 h-full w-px bg-foreground/15" />
</div>
{/* main content */}
<div className="relative flex flex-col items-center justify-center gap-5 pt-32 pb-30">
{/* X Content Faded Borders */}
<div
aria-hidden="true"
className="absolute inset-0 -z-1 size-full overflow-hidden"
>
<div className="absolute inset-y-0 left-4 w-px bg-linear-to-b from-transparent via-border to-border md:left-8" />
<div className="absolute inset-y-0 right-4 w-px bg-linear-to-b from-transparent via-border to-border md:right-8" />
<div className="absolute inset-y-0 left-8 w-px bg-linear-to-b from-transparent via-border/50 to-border/50 md:left-12" />
<div className="absolute inset-y-0 right-8 w-px bg-linear-to-b from-transparent via-border/50 to-border/50 md:right-12" />
</div>
<a
className={cn(
"group mx-auto flex w-fit items-center gap-3 rounded-full border bg-card px-3 py-1 shadow",
"fade-in slide-in-from-bottom-10 animate-in fill-mode-backwards transition-all delay-500 duration-500 ease-out"
)}
href="#link"
>
<RocketIcon className="size-3 text-muted-foreground" />
<span className="text-xs">shipped new features!</span>
<span className="block h-5 border-l" />
<ArrowRightIcon className="size-3 duration-150 ease-out group-hover:translate-x-1" />
</a>
<h1
className={cn(
"fade-in slide-in-from-bottom-10 animate-in text-balance fill-mode-backwards text-center text-4xl tracking-tight delay-100 duration-500 ease-out md:text-5xl lg:text-6xl",
"text-shadow-[0_0px_50px_theme(--color-foreground/.2)]"
)}
>
Building Teams Help <br /> You Scale and Lead
</h1>
<p className="fade-in slide-in-from-bottom-10 mx-auto max-w-md animate-in fill-mode-backwards text-center text-base text-foreground/80 tracking-wider delay-200 duration-500 ease-out sm:text-lg md:text-xl">
Conecting you with world-class talent <br /> to scale, innovate and
lead
</p>
<div className="fade-in slide-in-from-bottom-10 flex animate-in flex-row flex-wrap items-center justify-center gap-3 fill-mode-backwards pt-2 delay-300 duration-500 ease-out">
<Button className="rounded-full" size="lg" variant="secondary">
<PhoneCallIcon data-icon="inline-start" className="size-4 mr-2" />{" "}
Book a Call
</Button>
<Button className="rounded-full " size="lg">
Get started{" "}
<ArrowRightIcon
className="size-4 ms-2"data-icon="inline-end" />
</Button>
</div>
</div>
</section>
);
}
export function LogosSection() {
return (
<section className="relative space-y-4 border-t pt-6 pb-10">
<h2 className="text-center font-medium text-lg text-muted-foreground tracking-tight md:text-xl">
Trusted by <span className="text-foreground">experts</span>
</h2>
<div className="relative z-10 mx-auto max-w-4xl">
<LogoCloud logos={logos} />
</div>
</section>
);
}
const logos = [
{
src: "https://storage.efferd.com/logo/nvidia-wordmark.svg",
alt: "Nvidia Logo",
},
{
src: "https://storage.efferd.com/logo/supabase-wordmark.svg",
alt: "Supabase Logo",
},
{
src: "https://storage.efferd.com/logo/openai-wordmark.svg",
alt: "OpenAI Logo",
},
{
src: "https://storage.efferd.com/logo/turso-wordmark.svg",
alt: "Turso Logo",
},
{
src: "https://storage.efferd.com/logo/vercel-wordmark.svg",
alt: "Vercel Logo",
},
{
src: "https://storage.efferd.com/logo/github-wordmark.svg",
alt: "GitHub Logo",
},
{
src: "https://storage.efferd.com/logo/claude-wordmark.svg",
alt: "Claude AI Logo",
},
{
src: "https://storage.efferd.com/logo/clerk-wordmark.svg",
alt: "Clerk Logo",
},
];

View file

@ -0,0 +1,107 @@
'use client';
import { cn } from '@/utilities/ui';
import { useMotionValue, animate, motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import useMeasure from 'react-use-measure';
type InfiniteSliderProps = {
children: React.ReactNode;
gap?: number;
duration?: number;
durationOnHover?: number;
direction?: 'horizontal' | 'vertical';
reverse?: boolean;
className?: string;
};
export function InfiniteSlider({
children,
gap = 16,
duration = 25,
durationOnHover,
direction = 'horizontal',
reverse = false,
className,
}: InfiniteSliderProps) {
const [currentDuration, setCurrentDuration] = useState(duration);
const [ref, { width, height }] = useMeasure();
const translation = useMotionValue(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [key, setKey] = useState(0);
useEffect(() => {
let controls;
const size = direction === 'horizontal' ? width : height;
const contentSize = size + gap;
const from = reverse ? -contentSize / 2 : 0;
const to = reverse ? 0 : -contentSize / 2;
if (isTransitioning) {
controls = animate(translation, [translation.get(), to], {
ease: 'linear',
duration:
currentDuration * Math.abs((translation.get() - to) / contentSize),
onComplete: () => {
setIsTransitioning(false);
setKey((prevKey) => prevKey + 1);
},
});
} else {
controls = animate(translation, [from, to], {
ease: 'linear',
duration: currentDuration,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0,
onRepeat: () => {
translation.set(from);
},
});
}
return controls?.stop;
}, [
key,
translation,
currentDuration,
width,
height,
gap,
isTransitioning,
direction,
reverse,
]);
const hoverProps = durationOnHover
? {
onHoverStart: () => {
setIsTransitioning(true);
setCurrentDuration(durationOnHover);
},
onHoverEnd: () => {
setIsTransitioning(true);
setCurrentDuration(duration);
},
}
: {};
return (
<div className={cn('overflow-hidden', className)}>
<motion.div
className='flex w-max'
style={{
...(direction === 'horizontal'
? { x: translation }
: { y: translation }),
gap: `${gap}px`,
flexDirection: direction === 'horizontal' ? 'row' : 'column',
}}
ref={ref}
{...hoverProps}
>
{children}
{children}
</motion.div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { InfiniteSlider } from "@/components/ui/infinite-slider";
import { cn } from "@/utilities/ui";
type Logo = {
src: string;
alt: string;
width?: number;
height?: number;
};
type LogoCloudProps = React.ComponentProps<"div"> & {
logos: Logo[];
};
export function LogoCloud({ className, logos, ...props }: LogoCloudProps) {
return (
<div
{...props}
className={cn(
"overflow-hidden py-4 [mask-image:linear-gradient(to_right,transparent,black,transparent)]",
className
)}
>
<InfiniteSlider gap={42} reverse speed={80} speedOnHover={25}>
{logos.map((logo) => (
<img
alt={logo.alt}
className="pointer-events-none h-4 select-none md:h-5 dark:brightness-0 dark:invert"
height={logo.height || "auto"}
key={`logo-${logo.alt}`}
loading="lazy"
src={logo.src}
width={logo.width || "auto"}
/>
))}
</InfiniteSlider>
</div>
);
}

View file

@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { cn } from '@/utilities/ui';
type MenuToggleProps = React.ComponentProps<'svg'> & {
open: boolean;
duration?: number;
};
export function MenuToggleIcon({
open,
className,
fill = 'none',
stroke = 'currentColor',
strokeWidth = 2.5,
strokeLinecap = 'round',
strokeLinejoin = 'round',
duration = 500,
...props
}: MenuToggleProps) {
return (
<svg
strokeWidth={strokeWidth}
fill={fill}
stroke={stroke}
viewBox="0 0 32 32"
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
className={cn(
'transition-transform ease-in-out',
open && '-rotate-45',
className,
)}
style={{
transitionDuration: `${duration}ms`,
}}
{...props}
>
<path
className={cn(
'transition-all ease-in-out',
open
? '[stroke-dasharray:20_300] [stroke-dashoffset:-32.42px]'
: '[stroke-dasharray:12_63]',
)}
style={{
transitionDuration: `${duration}ms`,
}}
d="M27 10 13 10C10.8 10 9 8.2 9 6 9 3.5 10.8 2 13 2 15.2 2 17 3.8 17 6L17 26C17 28.2 18.8 30 21 30 23.2 30 25 28.2 25 26 25 23.8 23.2 22 21 22L7 22"
/>
<path d="M7 16 27 16" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
'use client';
import React from 'react';
export function useScroll(threshold: number) {
const [scrolled, setScrolled] = React.useState(false);
const onScroll = React.useCallback(() => {
setScrolled(window.scrollY > threshold);
}, [threshold]);
React.useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
// also check on first load
React.useEffect(() => {
onScroll();
}, [onScroll]);
return scrolled;
}