Initial commit: thinxtra.co company site
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.vite
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
README.md
|
||||||
|
docker-compose*.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.vite
|
||||||
|
.coolify
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
ARG NODE_VERSION=22-alpine
|
||||||
|
ARG NGINX_VERSION=1.27-alpine
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts index.html ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
|
||||||
|
FROM nginx:${NGINX_VERSION} AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache wget && rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
23
docker-compose.coolify.yml
Normal file
23
docker-compose.coolify.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
labels:
|
||||||
|
- "traefik.docker.network=coolify"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- coolify
|
||||||
|
|
||||||
|
networks:
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
80
index.html
Normal file
80
index.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0A0A0B" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="canonical" href="https://thinxtra.co/" />
|
||||||
|
|
||||||
|
<title>Thinxtra — SaaS Studio for Engineering Teams</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Thinxtra is a US-based SaaS studio. We design, engineer, and scale software products with senior teams that ship — from MVP to production at scale."
|
||||||
|
/>
|
||||||
|
<meta name="robots" content="index,follow" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Thinxtra" />
|
||||||
|
<meta property="og:title" content="Thinxtra — SaaS Studio for Engineering Teams" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="A senior product studio for SaaS teams. Strategy, design, and engineering — shipped weekly."
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="https://thinxtra.co/" />
|
||||||
|
<meta property="og:image" content="https://thinxtra.co/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Thinxtra — SaaS Studio for Engineering Teams" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="A senior product studio for SaaS teams. Strategy, design, and engineering — shipped weekly."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://thinxtra.co/og-image.png" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Thinxtra",
|
||||||
|
"alternateName": "Thinxtra Studio",
|
||||||
|
"url": "https://thinxtra.co",
|
||||||
|
"logo": "https://thinxtra.co/favicon.svg",
|
||||||
|
"description": "Thinxtra is a US-based SaaS studio. We design, engineer, and scale software products from MVP to production.",
|
||||||
|
"foundingDate": "2024",
|
||||||
|
"areaServed": "Worldwide",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressCountry": "US"
|
||||||
|
},
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"contactType": "Sales",
|
||||||
|
"email": "hello@thinxtra.co",
|
||||||
|
"availableLanguage": ["English"]
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.linkedin.com/company/thinxtra",
|
||||||
|
"https://x.com/thinxtra",
|
||||||
|
"https://github.com/thinxtra"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
nginx.conf
Normal file
63
nginx.conf
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
application/rss+xml
|
||||||
|
image/svg+xml
|
||||||
|
font/ttf
|
||||||
|
font/otf;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Permissions-Policy "interest-cohort=(), browsing-topics=()" always;
|
||||||
|
|
||||||
|
# Hashed asset bundles — long cache
|
||||||
|
location ~* ^/assets/.+\.(?:js|css|woff2?|ttf|otf|svg|png|jpe?g|webp|avif|gif|ico)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
access_log off;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static images / icons / data files (root)
|
||||||
|
location ~* \.(?:woff2?|ttf|otf|svg|png|jpe?g|webp|avif|gif|ico|xml|txt)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
access_log off;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: never cache the entry document
|
||||||
|
location / {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
add_header Expires "0" always;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health endpoint for Coolify / Traefik
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "thinxtra",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.29.3",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||||
|
"typecheck": "tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"lenis": "^1.1.20",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
1723
pnpm-lock.yaml
generated
Normal file
1723
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/favicon-32.png
Normal file
BIN
public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 789 B |
8
public/favicon.svg
Normal file
8
public/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#0A0A0B"/>
|
||||||
|
<g fill="none" stroke="#F5F5F7" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M7 9 L25 23"/>
|
||||||
|
<path d="M25 7 L9 25"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="24.5" cy="7.5" r="2" fill="#7C5CFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
BIN
public/og-image.png
Normal file
BIN
public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
44
public/og-image.svg
Normal file
44
public/og-image.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="g1" cx="22%" cy="40%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#7C5CFF" stop-opacity="0.55"/>
|
||||||
|
<stop offset="100%" stop-color="#7C5CFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="g2" cx="80%" cy="65%" r="48%">
|
||||||
|
<stop offset="0%" stop-color="#22D3EE" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#22D3EE" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="g3" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F5F5F7"/>
|
||||||
|
<stop offset="0.7" stop-color="#A78BFA"/>
|
||||||
|
<stop offset="1" stop-color="#22D3EE"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#1F1F23" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="630" fill="#0A0A0B"/>
|
||||||
|
<rect width="1200" height="630" fill="url(#grid)" opacity="0.6"/>
|
||||||
|
<rect width="1200" height="630" fill="url(#g1)"/>
|
||||||
|
<rect width="1200" height="630" fill="url(#g2)"/>
|
||||||
|
|
||||||
|
<g transform="translate(80,90)" fill="none" stroke="#F5F5F7" stroke-width="3" stroke-linecap="round">
|
||||||
|
<path d="M0 8 L40 40"/>
|
||||||
|
<path d="M40 0 L4 36"/>
|
||||||
|
<circle cx="38" cy="2" r="4" fill="#7C5CFF" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<text x="138" y="125" fill="#F5F5F7" font-family="Inter, sans-serif" font-size="28" font-weight="600" letter-spacing="-0.5">thinxtra</text>
|
||||||
|
|
||||||
|
<text x="80" y="320" fill="url(#g3)" font-family="Inter, sans-serif" font-size="104" font-weight="700" letter-spacing="-4">SaaS studio for</text>
|
||||||
|
<text x="80" y="430" fill="url(#g3)" font-family="Inter, sans-serif" font-size="104" font-weight="700" letter-spacing="-4">teams that ship.</text>
|
||||||
|
|
||||||
|
<text x="80" y="510" fill="#8A8A92" font-family="Inter, sans-serif" font-size="26" font-weight="400">Strategy · Design · Engineering — shipped weekly.</text>
|
||||||
|
|
||||||
|
<g transform="translate(80,560)" font-family="JetBrains Mono, monospace" font-size="14" fill="#8A8A92">
|
||||||
|
<text x="0" y="14">thinxtra.co</text>
|
||||||
|
<circle cx="120" cy="9" r="3" fill="#7C5CFF"/>
|
||||||
|
<text x="135" y="14">United States</text>
|
||||||
|
<circle cx="280" cy="9" r="3" fill="#22D3EE"/>
|
||||||
|
<text x="295" y="14">Senior team</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
5
public/robots.txt
Normal file
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /healthz
|
||||||
|
|
||||||
|
Sitemap: https://thinxtra.co/sitemap.xml
|
||||||
8
public/sitemap.xml
Normal file
8
public/sitemap.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://thinxtra.co/</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
24
scripts/gen-images.mjs
Normal file
24
scripts/gen-images.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { Resvg } from '@resvg/resvg-js'
|
||||||
|
|
||||||
|
const ogSvg = readFileSync('public/og-image.svg', 'utf8')
|
||||||
|
const og = new Resvg(ogSvg, {
|
||||||
|
fitTo: { mode: 'width', value: 1200 },
|
||||||
|
background: '#0A0A0B',
|
||||||
|
})
|
||||||
|
writeFileSync('public/og-image.png', og.render().asPng())
|
||||||
|
console.log('og-image.png written')
|
||||||
|
|
||||||
|
const faviconSvg = readFileSync('public/favicon.svg', 'utf8')
|
||||||
|
const apple = new Resvg(faviconSvg, {
|
||||||
|
fitTo: { mode: 'width', value: 180 },
|
||||||
|
background: '#0A0A0B',
|
||||||
|
})
|
||||||
|
writeFileSync('public/apple-touch-icon.png', apple.render().asPng())
|
||||||
|
console.log('apple-touch-icon.png written')
|
||||||
|
|
||||||
|
const fav32 = new Resvg(faviconSvg, {
|
||||||
|
fitTo: { mode: 'width', value: 32 },
|
||||||
|
})
|
||||||
|
writeFileSync('public/favicon-32.png', fav32.render().asPng())
|
||||||
|
console.log('favicon-32.png written')
|
||||||
34
src/App.tsx
Normal file
34
src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useLenis } from '@/lib/lenis'
|
||||||
|
import { useCursorBlob } from '@/lib/cursor'
|
||||||
|
import { Nav } from '@/components/Nav'
|
||||||
|
import { Hero } from '@/components/Hero'
|
||||||
|
import { Services } from '@/components/Services'
|
||||||
|
import { Capabilities } from '@/components/Capabilities'
|
||||||
|
import { Process } from '@/components/Process'
|
||||||
|
import { Work } from '@/components/Work'
|
||||||
|
import { About } from '@/components/About'
|
||||||
|
import { FAQ } from '@/components/FAQ'
|
||||||
|
import { CTABand } from '@/components/CTABand'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
useLenis()
|
||||||
|
useCursorBlob()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<main className="relative">
|
||||||
|
<Hero />
|
||||||
|
<Services />
|
||||||
|
<Capabilities />
|
||||||
|
<Process />
|
||||||
|
<Work />
|
||||||
|
<About />
|
||||||
|
<FAQ />
|
||||||
|
<CTABand />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/assets/logo.svg
Normal file
5
src/assets/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
|
||||||
|
<path d="M5 7 L23 21" />
|
||||||
|
<path d="M23 5 L7 23" />
|
||||||
|
<circle cx="22.5" cy="5.5" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 296 B |
69
src/components/About.tsx
Normal file
69
src/components/About.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Counter } from './ui/Counter'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Products shipped', to: 47, suffix: '+' },
|
||||||
|
{ label: 'Avg. time to first ship', to: 14, suffix: ' days' },
|
||||||
|
{ label: 'Client NPS (last 12 mo)', to: 72, suffix: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function About() {
|
||||||
|
return (
|
||||||
|
<section id="about" className="relative py-28 md:py-36">
|
||||||
|
<div className="container-x grid md:grid-cols-[1.3fr_1fr] gap-14 items-end">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
The studio
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display text-[clamp(2rem,4.6vw,3.5rem)] tracking-tight"
|
||||||
|
>
|
||||||
|
We don't scale by hiring juniors.{' '}
|
||||||
|
<span className="text-[var(--color-muted)]">
|
||||||
|
We scale by being deliberate about who we work with.
|
||||||
|
</span>
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-6 max-w-2xl text-[var(--color-muted)] leading-relaxed text-base md:text-lg"
|
||||||
|
>
|
||||||
|
Thinxtra is a small US-based studio of engineers, designers, and product partners with a
|
||||||
|
decade of work in venture-backed SaaS. We take on a handful of engagements at a time so
|
||||||
|
every team gets the people they hired — and the bar stays where it should be.
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.dl
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-1 gap-5"
|
||||||
|
>
|
||||||
|
{stats.map((s) => (
|
||||||
|
<motion.div
|
||||||
|
key={s.label}
|
||||||
|
variants={fadeUp}
|
||||||
|
className="card p-6 md:p-7"
|
||||||
|
>
|
||||||
|
<dt className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted-2)]">
|
||||||
|
{s.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-3 text-display text-4xl md:text-5xl tracking-tight">
|
||||||
|
<Counter to={s.to} suffix={s.suffix} />
|
||||||
|
</dd>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/components/CTABand.tsx
Normal file
64
src/components/CTABand.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ArrowUpRight, Mail } from 'lucide-react'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
export function CTABand() {
|
||||||
|
return (
|
||||||
|
<section id="contact" className="relative py-24 md:py-28">
|
||||||
|
<div className="container-x">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-10% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="relative overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-hairline)] p-10 md:p-16"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(120% 120% at 0% 0%, rgba(124,92,255,0.18), transparent 55%), radial-gradient(120% 120% at 100% 100%, rgba(34,211,238,0.12), transparent 55%), var(--color-surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 grid-bg opacity-40"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 grid md:grid-cols-[1.4fr_1fr] gap-10 items-end">
|
||||||
|
<div>
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
Let's talk
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display gradient-text text-[clamp(2.25rem,5vw,4.25rem)] tracking-tight max-w-3xl"
|
||||||
|
>
|
||||||
|
Have a problem worth solving?
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-5 max-w-xl text-[var(--color-muted)] leading-relaxed"
|
||||||
|
>
|
||||||
|
Send us a paragraph about what you're building. We'll come back within a
|
||||||
|
business day with whether — and how — we can help.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
<motion.div variants={fadeUp} className="flex flex-col items-stretch gap-3 md:items-end">
|
||||||
|
<a
|
||||||
|
href="mailto:hello@thinxtra.co?subject=Project%20enquiry"
|
||||||
|
className="btn btn-primary justify-between md:justify-center md:px-6"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Mail size={16} />
|
||||||
|
hello@thinxtra.co
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
<p className="text-xs font-mono text-[var(--color-muted-2)] uppercase tracking-[0.18em] text-right">
|
||||||
|
Replies within 1 business day · United States · GMT-5
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/components/Capabilities.tsx
Normal file
53
src/components/Capabilities.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { capabilities } from '@/content/capabilities'
|
||||||
|
|
||||||
|
export function Capabilities() {
|
||||||
|
const row = [...capabilities, ...capabilities]
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Capabilities"
|
||||||
|
className="relative border-y border-[var(--color-hairline)] py-10 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, color-mix(in oklab, var(--color-surface) 60%, transparent), transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-32 z-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, var(--color-bg), transparent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-0 w-32 z-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to left, var(--color-bg), transparent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="marquee-track"
|
||||||
|
animate={{ x: ['0%', '-50%'] }}
|
||||||
|
transition={{
|
||||||
|
duration: 36,
|
||||||
|
ease: 'linear',
|
||||||
|
repeat: Infinity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.map((cap, i) => (
|
||||||
|
<span
|
||||||
|
key={cap + i}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-sm text-[var(--color-muted)] whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-violet)]" />
|
||||||
|
{cap}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
src/components/FAQ.tsx
Normal file
104
src/components/FAQ.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { faq } from '@/content/faq'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
export function FAQ() {
|
||||||
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="faq" className="relative py-28 md:py-36">
|
||||||
|
<div className="container-x grid md:grid-cols-[1fr_1.6fr] gap-12">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
FAQ
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display text-[clamp(2rem,4.6vw,3.5rem)] tracking-tight"
|
||||||
|
>
|
||||||
|
Common questions.
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-5 text-[var(--color-muted)] leading-relaxed"
|
||||||
|
>
|
||||||
|
Don't see yours? Email{' '}
|
||||||
|
<a
|
||||||
|
className="underline-link"
|
||||||
|
href="mailto:hello@thinxtra.co"
|
||||||
|
>
|
||||||
|
hello@thinxtra.co
|
||||||
|
</a>{' '}
|
||||||
|
— we reply within a business day.
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.ul
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-10% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="divide-y divide-[var(--color-hairline)] border-y border-[var(--color-hairline)]"
|
||||||
|
>
|
||||||
|
{faq.map((item, i) => {
|
||||||
|
const isOpen = open === i
|
||||||
|
return (
|
||||||
|
<motion.li key={item.q} variants={fadeUp}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
|
className="w-full flex items-center justify-between gap-6 py-6 text-left group"
|
||||||
|
>
|
||||||
|
<span className="text-base md:text-lg font-medium tracking-tight pr-4">
|
||||||
|
{item.q}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'shrink-0 flex h-9 w-9 items-center justify-center rounded-full border transition-colors ' +
|
||||||
|
(isOpen
|
||||||
|
? 'bg-[var(--color-violet)] border-[var(--color-violet)] text-white'
|
||||||
|
: 'border-[var(--color-hairline)] text-[var(--color-muted)] group-hover:text-[var(--color-text)]')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: isOpen ? 45 : 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.2, 0.8, 0.2, 1] }}
|
||||||
|
className="flex"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
key="content"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.2, 0.8, 0.2, 1] }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="pb-6 pr-12 text-[var(--color-muted)] leading-relaxed">
|
||||||
|
{item.a}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/components/Footer.tsx
Normal file
105
src/components/Footer.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Github, Linkedin } from 'lucide-react'
|
||||||
|
import logo from '@/assets/logo.svg'
|
||||||
|
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="relative border-t border-[var(--color-hairline)] py-12 md:py-16">
|
||||||
|
<div className="container-x grid gap-10 md:grid-cols-[1.4fr_1fr_1fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-text)]">
|
||||||
|
<img src={logo} alt="" width={22} height={22} />
|
||||||
|
<span className="font-semibold tracking-tight">thinxtra</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 max-w-sm text-sm text-[var(--color-muted)] leading-relaxed">
|
||||||
|
A US-based SaaS studio. Senior teams designing, engineering, and scaling software
|
||||||
|
products that matter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-[11px] uppercase tracking-[0.2em] text-[var(--color-muted-2)]">
|
||||||
|
Studio
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<li>
|
||||||
|
<a href="#services" className="underline-link">
|
||||||
|
Services
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#process" className="underline-link">
|
||||||
|
Process
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#work" className="underline-link">
|
||||||
|
Work
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#about" className="underline-link">
|
||||||
|
Studio
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-[11px] uppercase tracking-[0.2em] text-[var(--color-muted-2)]">
|
||||||
|
Contact
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<li>
|
||||||
|
<a href="mailto:hello@thinxtra.co" className="underline-link">
|
||||||
|
hello@thinxtra.co
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>United States · Remote</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-[11px] uppercase tracking-[0.2em] text-[var(--color-muted-2)]">
|
||||||
|
Elsewhere
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-4 flex items-center gap-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
href="https://www.linkedin.com/company/thinxtra"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-muted)] hover:text-[var(--color-text)] hover:border-[var(--color-hairline-2)] transition"
|
||||||
|
>
|
||||||
|
<Linkedin size={15} />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
aria-label="GitHub"
|
||||||
|
href="https://github.com/thinxtra"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-muted)] hover:text-[var(--color-text)] hover:border-[var(--color-hairline-2)] transition"
|
||||||
|
>
|
||||||
|
<Github size={15} />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
aria-label="X"
|
||||||
|
href="https://x.com/thinxtra"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-muted)] hover:text-[var(--color-text)] hover:border-[var(--color-hairline-2)] transition"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm">X</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-x mt-12 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 pt-6 border-t border-[var(--color-hairline)] text-xs font-mono text-[var(--color-muted-2)] uppercase tracking-[0.18em]">
|
||||||
|
<span>© {year} Thinxtra Studio · All rights reserved</span>
|
||||||
|
<span>Built in TypeScript · Deployed on Coolify</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
src/components/Hero.tsx
Normal file
147
src/components/Hero.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ArrowDown, ArrowUpRight } from 'lucide-react'
|
||||||
|
import { GradientOrb } from './ui/GradientOrb'
|
||||||
|
import { GridBackground } from './ui/GridBackground'
|
||||||
|
import { scrollToId } from '@/lib/lenis'
|
||||||
|
import { trust } from '@/content/capabilities'
|
||||||
|
|
||||||
|
const HEADLINE_LINES = [
|
||||||
|
['Software', 'studios', 'for'],
|
||||||
|
['teams', 'that', 'ship.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
const root = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (!root.current) return
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const words = root.current!.querySelectorAll<HTMLElement>('[data-anim="word"]')
|
||||||
|
const sub = root.current!.querySelector('[data-anim="sub"]')
|
||||||
|
const cta = root.current!.querySelector('[data-anim="cta"]')
|
||||||
|
const meta = root.current!.querySelector('[data-anim="meta"]')
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
gsap.set([words, sub, cta, meta], { autoAlpha: 1, y: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(words, { y: '110%', opacity: 0 })
|
||||||
|
gsap.set([sub, cta, meta], { y: 24, opacity: 0 })
|
||||||
|
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } })
|
||||||
|
tl.to(words, {
|
||||||
|
y: '0%',
|
||||||
|
opacity: 1,
|
||||||
|
duration: 1.1,
|
||||||
|
stagger: 0.05,
|
||||||
|
delay: 0.1,
|
||||||
|
})
|
||||||
|
.to(sub, { y: 0, opacity: 1, duration: 0.8 }, '-=0.6')
|
||||||
|
.to(cta, { y: 0, opacity: 1, duration: 0.7 }, '-=0.5')
|
||||||
|
.to(meta, { y: 0, opacity: 1, duration: 0.7 }, '-=0.5')
|
||||||
|
}, root)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={root}
|
||||||
|
id="top"
|
||||||
|
className="relative isolate overflow-hidden pt-36 pb-28 md:pt-44 md:pb-36"
|
||||||
|
>
|
||||||
|
<GridBackground />
|
||||||
|
<GradientOrb />
|
||||||
|
|
||||||
|
<div className="container-x relative z-10">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<span className="pill">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
Now booking Q3 engagements
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-display text-center text-[clamp(2.75rem,7.4vw,6.5rem)] mx-auto max-w-[18ch]">
|
||||||
|
{HEADLINE_LINES.map((line, li) => (
|
||||||
|
<span key={li} className="block overflow-hidden pb-1">
|
||||||
|
{line.map((word, wi) => (
|
||||||
|
<span
|
||||||
|
key={wi}
|
||||||
|
data-anim="word"
|
||||||
|
className="inline-block will-change-transform gradient-text"
|
||||||
|
style={{ marginRight: '0.18em' }}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
data-anim="sub"
|
||||||
|
className="mx-auto mt-7 max-w-2xl text-center text-[var(--color-muted)] text-base md:text-lg leading-relaxed"
|
||||||
|
>
|
||||||
|
Thinxtra is a US-based SaaS studio. Senior engineers, designers, and product partners —
|
||||||
|
embedded with you, shipping production every week from idea to scale.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-anim="cta"
|
||||||
|
className="mt-9 flex flex-col sm:flex-row items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
scrollToId('contact')
|
||||||
|
}}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Start a project
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#work"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
scrollToId('work')
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Selected work
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-anim="meta"
|
||||||
|
className="mt-20 flex flex-col items-center gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-mono text-[var(--color-muted-2)] uppercase tracking-[0.18em]">
|
||||||
|
Backed teams from
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap justify-center gap-x-7 gap-y-3 text-sm text-[var(--color-muted)]">
|
||||||
|
{trust.map((t) => (
|
||||||
|
<li key={t} className="font-medium tracking-tight">
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Scroll to services"
|
||||||
|
onClick={() => scrollToId('services')}
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 bottom-2 hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-[0.2em] text-[var(--color-muted-2)] hover:text-[var(--color-text)] transition-colors"
|
||||||
|
>
|
||||||
|
Scroll
|
||||||
|
<ArrowDown size={14} className="animate-bounce" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
src/components/Nav.tsx
Normal file
108
src/components/Nav.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Menu, X, ArrowUpRight } from 'lucide-react'
|
||||||
|
import { scrollToId } from '@/lib/lenis'
|
||||||
|
import logo from '@/assets/logo.svg'
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ id: 'services', label: 'Services' },
|
||||||
|
{ id: 'process', label: 'Process' },
|
||||||
|
{ id: 'work', label: 'Work' },
|
||||||
|
{ id: 'about', label: 'Studio' },
|
||||||
|
{ id: 'faq', label: 'FAQ' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 12)
|
||||||
|
onScroll()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLAnchorElement>, id: string) {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(false)
|
||||||
|
scrollToId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'fixed inset-x-0 top-0 z-50 transition-all duration-300 ' +
|
||||||
|
(scrolled
|
||||||
|
? 'backdrop-blur-xl bg-[color-mix(in_oklab,var(--color-bg)_75%,transparent)] border-b border-[var(--color-hairline)]'
|
||||||
|
: 'border-b border-transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav className="container-x flex h-16 items-center justify-between gap-6">
|
||||||
|
<a
|
||||||
|
href="#top"
|
||||||
|
onClick={(e) => handleClick(e, 'top')}
|
||||||
|
className="flex items-center gap-2 text-[var(--color-text)]"
|
||||||
|
aria-label="Thinxtra — home"
|
||||||
|
>
|
||||||
|
<img src={logo} alt="" width={24} height={24} className="text-[var(--color-text)]" />
|
||||||
|
<span className="text-[15px] font-semibold tracking-tight">thinxtra</span>
|
||||||
|
<span className="ml-1 hidden sm:inline pill" style={{ padding: '0.15rem 0.5rem' }}>
|
||||||
|
<span className="pill-dot" />
|
||||||
|
US studio
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul className="hidden md:flex items-center gap-7 text-sm text-[var(--color-muted)]">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<a
|
||||||
|
href={'#' + l.id}
|
||||||
|
onClick={(e) => handleClick(e, l.id)}
|
||||||
|
className="underline-link hover:text-[var(--color-text)] transition-colors"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
onClick={(e) => handleClick(e, 'contact')}
|
||||||
|
className="btn btn-primary text-sm h-9 px-4"
|
||||||
|
>
|
||||||
|
Start a project
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
className="md:hidden btn btn-ghost h-9 w-9 px-0"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? <X size={18} /> : <Menu size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="md:hidden border-t border-[var(--color-hairline)] bg-[var(--color-bg)]">
|
||||||
|
<ul className="container-x py-4 flex flex-col gap-2">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<a
|
||||||
|
href={'#' + l.id}
|
||||||
|
onClick={(e) => handleClick(e, l.id)}
|
||||||
|
className="block py-2 text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/components/Process.tsx
Normal file
122
src/components/Process.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import { process } from '@/content/process'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
export function Process() {
|
||||||
|
const root = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!root.current) return
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reduced) return
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const bar = root.current!.querySelector('[data-process-bar]')
|
||||||
|
if (!bar) return
|
||||||
|
gsap.fromTo(
|
||||||
|
bar,
|
||||||
|
{ scaleY: 0 },
|
||||||
|
{
|
||||||
|
scaleY: 1,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: root.current,
|
||||||
|
start: 'top 70%',
|
||||||
|
end: 'bottom 60%',
|
||||||
|
scrub: 0.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, root)
|
||||||
|
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="process" className="relative py-28 md:py-36">
|
||||||
|
<div className="container-x">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="max-w-2xl"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
How we work
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display text-[clamp(2rem,4.6vw,3.5rem)] tracking-tight"
|
||||||
|
>
|
||||||
|
Predictable rhythm.{' '}
|
||||||
|
<span className="text-[var(--color-muted)]">No mystery, no theatre.</span>
|
||||||
|
</motion.h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div ref={root} className="relative mt-16 grid md:grid-cols-[1fr_2fr] gap-10 md:gap-16">
|
||||||
|
<div className="hidden md:block sticky top-28 self-start">
|
||||||
|
<div className="font-mono text-xs uppercase tracking-[0.2em] text-[var(--color-muted-2)]">
|
||||||
|
Engagement timeline
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 relative h-72 w-px bg-[var(--color-hairline)]">
|
||||||
|
<div
|
||||||
|
data-process-bar
|
||||||
|
className="absolute inset-0 origin-top w-px bg-gradient-to-b from-[var(--color-violet)] via-[var(--color-violet-2)] to-[var(--color-cyan)]"
|
||||||
|
style={{ transform: 'scaleY(0)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.ol
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-10% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{process.map((step) => (
|
||||||
|
<motion.li
|
||||||
|
key={step.id}
|
||||||
|
variants={fadeUp}
|
||||||
|
className="card p-7 md:p-9"
|
||||||
|
>
|
||||||
|
<div className="card-glow" />
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<span className="font-mono text-sm text-[var(--color-violet-2)]">
|
||||||
|
{step.index}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--color-muted-2)]">
|
||||||
|
{step.duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-[var(--color-muted)] leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{step.outputs.map((o) => (
|
||||||
|
<li
|
||||||
|
key={o}
|
||||||
|
className="font-mono text-xs px-3 py-1.5 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface)] text-[var(--color-muted)]"
|
||||||
|
>
|
||||||
|
{o}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</motion.ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/components/Services.tsx
Normal file
83
src/components/Services.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ArrowUpRight } from 'lucide-react'
|
||||||
|
import { services } from '@/content/services'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
export function Services() {
|
||||||
|
return (
|
||||||
|
<section id="services" className="relative py-28 md:py-36">
|
||||||
|
<div className="container-x">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="max-w-2xl"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
What we do
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display text-[clamp(2rem,4.6vw,3.5rem)] tracking-tight"
|
||||||
|
>
|
||||||
|
A senior product team —{' '}
|
||||||
|
<span className="text-[var(--color-muted)]">embedded, on demand.</span>
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-5 text-[var(--color-muted)] text-base md:text-lg leading-relaxed"
|
||||||
|
>
|
||||||
|
Four practices, one team. We bring strategy, design, and engineering under one roof so
|
||||||
|
your roadmap moves in weeks — not quarters.
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.ul
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-10% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="mt-14 grid grid-cols-1 md:grid-cols-2 gap-5"
|
||||||
|
>
|
||||||
|
{services.map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
key={s.id}
|
||||||
|
variants={fadeUp}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 250, damping: 22 }}
|
||||||
|
className="card p-7 md:p-8"
|
||||||
|
>
|
||||||
|
<div className="card-glow" />
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--color-hairline)] bg-[var(--color-surface)] text-[var(--color-violet-2)]">
|
||||||
|
<Icon size={20} strokeWidth={1.6} />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight
|
||||||
|
size={18}
|
||||||
|
className="text-[var(--color-muted-2)] group-hover:text-[var(--color-text)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl md:text-2xl font-semibold tracking-tight">
|
||||||
|
{s.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-[var(--color-muted)] leading-relaxed">{s.summary}</p>
|
||||||
|
<ul className="mt-6 space-y-2 font-mono text-[13px] text-[var(--color-muted)]">
|
||||||
|
{s.bullets.map((b) => (
|
||||||
|
<li key={b} className="flex items-center gap-2">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-[var(--color-violet)]" />
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
src/components/Work.tsx
Normal file
99
src/components/Work.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ArrowUpRight } from 'lucide-react'
|
||||||
|
import { work } from '@/content/work'
|
||||||
|
import { fadeUp, stagger } from '@/lib/motion'
|
||||||
|
|
||||||
|
export function Work() {
|
||||||
|
return (
|
||||||
|
<section id="work" className="relative py-28 md:py-36">
|
||||||
|
<div className="container-x">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-15% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-6"
|
||||||
|
>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<motion.div variants={fadeUp} className="pill mb-6">
|
||||||
|
<span className="pill-dot" />
|
||||||
|
Selected work
|
||||||
|
</motion.div>
|
||||||
|
<motion.h2
|
||||||
|
variants={fadeUp}
|
||||||
|
className="text-display text-[clamp(2rem,4.6vw,3.5rem)] tracking-tight"
|
||||||
|
>
|
||||||
|
Products we helped{' '}
|
||||||
|
<span className="text-[var(--color-muted)]">put into the world.</span>
|
||||||
|
</motion.h2>
|
||||||
|
</div>
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="md:max-w-xs text-[var(--color-muted)] leading-relaxed"
|
||||||
|
>
|
||||||
|
A snapshot. We work under NDA on the rest. Reach out for full case studies and
|
||||||
|
references.
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: '-10% 0px' }}
|
||||||
|
variants={stagger}
|
||||||
|
className="mt-14 grid grid-cols-1 lg:grid-cols-3 gap-5"
|
||||||
|
>
|
||||||
|
{work.map((w) => (
|
||||||
|
<motion.article
|
||||||
|
key={w.id}
|
||||||
|
variants={fadeUp}
|
||||||
|
whileHover={{ y: -6 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 220, damping: 20 }}
|
||||||
|
className="card group p-7 md:p-8 flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="card-glow" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`relative h-44 -mx-7 md:-mx-8 -mt-7 md:-mt-8 mb-6 overflow-hidden rounded-t-[var(--radius-card)] bg-gradient-to-br ${w.gradient}`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 grid-bg opacity-50" />
|
||||||
|
<div className="absolute inset-0 flex items-end p-5">
|
||||||
|
<span className="font-mono text-xs text-[var(--color-text)]/70 uppercase tracking-[0.18em]">
|
||||||
|
{w.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="font-mono text-xs text-[var(--color-muted-2)] uppercase tracking-[0.18em]">
|
||||||
|
{w.client}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight
|
||||||
|
size={16}
|
||||||
|
className="text-[var(--color-muted-2)] group-hover:text-[var(--color-text)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-xl md:text-[1.4rem] font-semibold tracking-tight">
|
||||||
|
{w.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-[var(--color-muted)] leading-relaxed text-[0.95rem]">
|
||||||
|
{w.blurb}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid grid-cols-2 gap-4 pt-5 border-t border-[var(--color-hairline)]">
|
||||||
|
{w.metrics.map((m) => (
|
||||||
|
<div key={m.label}>
|
||||||
|
<dt className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted-2)]">
|
||||||
|
{m.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-lg font-semibold tracking-tight">{m.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ui/Button.tsx
Normal file
36
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'ghost'
|
||||||
|
|
||||||
|
interface CommonProps {
|
||||||
|
children: ReactNode
|
||||||
|
variant?: Variant
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsAnchor = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }
|
||||||
|
type AsButton = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: undefined }
|
||||||
|
|
||||||
|
export function Button(props: AsAnchor | AsButton) {
|
||||||
|
const { variant = 'primary', className = '', children, ...rest } = props as CommonProps & {
|
||||||
|
href?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
const base = 'btn ' + (variant === 'primary' ? 'btn-primary' : 'btn-ghost') + ' ' + className
|
||||||
|
|
||||||
|
if ('href' in rest && rest.href) {
|
||||||
|
const { href, ...anchorRest } = rest as AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={href} className={base} {...anchorRest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className={base} {...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/components/ui/Counter.tsx
Normal file
41
src/components/ui/Counter.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useInView } from 'framer-motion'
|
||||||
|
|
||||||
|
interface CounterProps {
|
||||||
|
to: number
|
||||||
|
suffix?: string
|
||||||
|
duration?: number
|
||||||
|
decimals?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Counter({ to, suffix = '', duration = 1.6, decimals = 0 }: CounterProps) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null)
|
||||||
|
const inView = useInView(ref, { once: true, margin: '-10% 0px' })
|
||||||
|
const [value, setValue] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) return
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reduced) {
|
||||||
|
setValue(to)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
const ease = (t: number) => 1 - Math.pow(1 - t, 3)
|
||||||
|
let raf = 0
|
||||||
|
function tick(now: number) {
|
||||||
|
const t = Math.min(1, (now - start) / (duration * 1000))
|
||||||
|
setValue(to * ease(t))
|
||||||
|
if (t < 1) raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [inView, to, duration])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref}>
|
||||||
|
{value.toFixed(decimals)}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/components/ui/GradientOrb.tsx
Normal file
39
src/components/ui/GradientOrb.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
|
||||||
|
export function GradientOrb() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reduced || !ref.current) return
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
gsap.to(ref.current, {
|
||||||
|
x: 60,
|
||||||
|
y: -40,
|
||||||
|
scale: 1.08,
|
||||||
|
duration: 8,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
})
|
||||||
|
}, ref)
|
||||||
|
return () => ctx.revert()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={ref}
|
||||||
|
className="pointer-events-none absolute -z-0 left-1/2 top-1/3 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
style={{
|
||||||
|
width: 'min(1100px, 110vw)',
|
||||||
|
height: 'min(700px, 70vw)',
|
||||||
|
filter: 'blur(80px)',
|
||||||
|
background:
|
||||||
|
'radial-gradient(50% 50% at 35% 50%, rgba(124,92,255,0.55) 0%, rgba(124,92,255,0) 70%), radial-gradient(45% 60% at 75% 60%, rgba(34,211,238,0.32) 0%, rgba(34,211,238,0) 70%)',
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/components/ui/GridBackground.tsx
Normal file
8
src/components/ui/GridBackground.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function GridBackground() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 grid-bg"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/content/capabilities.ts
Normal file
31
src/content/capabilities.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const capabilities: string[] = [
|
||||||
|
'TypeScript',
|
||||||
|
'React',
|
||||||
|
'Next.js',
|
||||||
|
'NestJS',
|
||||||
|
'Postgres',
|
||||||
|
'Redis',
|
||||||
|
'AWS',
|
||||||
|
'Vercel',
|
||||||
|
'Stripe',
|
||||||
|
'OpenAI',
|
||||||
|
'Anthropic',
|
||||||
|
'pgvector',
|
||||||
|
'Tailwind CSS',
|
||||||
|
'Framer Motion',
|
||||||
|
'Playwright',
|
||||||
|
'GitHub Actions',
|
||||||
|
'Terraform',
|
||||||
|
'OpenTelemetry',
|
||||||
|
'Sentry',
|
||||||
|
'Linear',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const trust: string[] = [
|
||||||
|
'Y Combinator',
|
||||||
|
'Andreessen Horowitz',
|
||||||
|
'Sequoia',
|
||||||
|
'Lightspeed',
|
||||||
|
'Index Ventures',
|
||||||
|
'First Round',
|
||||||
|
]
|
||||||
31
src/content/faq.ts
Normal file
31
src/content/faq.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface FAQItem {
|
||||||
|
q: string
|
||||||
|
a: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const faq: FAQItem[] = [
|
||||||
|
{
|
||||||
|
q: 'Are you a US company?',
|
||||||
|
a: 'Yes. Thinxtra is incorporated in the United States. Our team is fully remote with senior engineers and designers across North America and Europe.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'How do you price?',
|
||||||
|
a: 'Two models: (1) fixed-scope discovery and design sprints with milestone billing; (2) an embedded senior pod billed monthly. Most teams start with a 2-week paid discovery before committing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'How fast can you start?',
|
||||||
|
a: 'Typically two to three weeks from a signed engagement. We are deliberate about staffing — we do not take on work we cannot ship with senior people.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Who owns the IP?',
|
||||||
|
a: 'You do, in full. Code, design files, dashboards, runbooks, and credentials transfer to your team at every milestone.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'What stacks do you work in?',
|
||||||
|
a: 'Default: TypeScript, React, Node/NestJS, Postgres, AWS or Vercel, OpenAI/Anthropic. We can adapt — but only when there is a real reason to.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Do you sign NDAs and BAAs?',
|
||||||
|
a: 'Yes. We routinely sign NDAs, MSAs, DPAs, and BAAs. Reach out if you have a security questionnaire — we will turn it around quickly.',
|
||||||
|
},
|
||||||
|
]
|
||||||
47
src/content/process.ts
Normal file
47
src/content/process.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface ProcessStep {
|
||||||
|
id: string
|
||||||
|
index: string
|
||||||
|
title: string
|
||||||
|
duration: string
|
||||||
|
description: string
|
||||||
|
outputs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const process: ProcessStep[] = [
|
||||||
|
{
|
||||||
|
id: 'discover',
|
||||||
|
index: '01',
|
||||||
|
title: 'Discover',
|
||||||
|
duration: 'Week 1',
|
||||||
|
description:
|
||||||
|
'We map your users, revenue model, and constraints. No deck — a written brief and an opinionated direction.',
|
||||||
|
outputs: ['Product brief', 'Architecture options', 'Risk register'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'design',
|
||||||
|
index: '02',
|
||||||
|
title: 'Design',
|
||||||
|
duration: 'Week 2–3',
|
||||||
|
description:
|
||||||
|
'Interaction flows, design system tokens, and a clickable prototype your team can pressure-test.',
|
||||||
|
outputs: ['Figma library', 'Prototype', 'Token + theme spec'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'build',
|
||||||
|
index: '03',
|
||||||
|
title: 'Build',
|
||||||
|
duration: 'Week 3–10',
|
||||||
|
description:
|
||||||
|
'A senior pod ships features behind flags. Demos every Friday. Production from the first PR.',
|
||||||
|
outputs: ['Deployed product', 'Test + CI suite', 'Runbooks'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scale',
|
||||||
|
index: '04',
|
||||||
|
title: 'Scale',
|
||||||
|
duration: 'Ongoing',
|
||||||
|
description:
|
||||||
|
'Observability, on-call, and a quarterly roadmap. We hand off to your team — or stay as the engineering org until you hire one.',
|
||||||
|
outputs: ['SLOs + dashboards', 'Quarterly OKRs', 'Hiring plan'],
|
||||||
|
},
|
||||||
|
]
|
||||||
66
src/content/services.ts
Normal file
66
src/content/services.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Cpu,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
bullets: string[]
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export const services: Service[] = [
|
||||||
|
{
|
||||||
|
id: 'product',
|
||||||
|
title: 'SaaS Product Engineering',
|
||||||
|
summary:
|
||||||
|
'A senior pod that owns delivery — frontend, backend, infra, and design — under one roof.',
|
||||||
|
bullets: [
|
||||||
|
'TypeScript · React · Node · Postgres',
|
||||||
|
'Weekly demos, biweekly releases',
|
||||||
|
'Production from day one',
|
||||||
|
],
|
||||||
|
icon: Boxes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mvp',
|
||||||
|
title: 'MVP to Scale',
|
||||||
|
summary:
|
||||||
|
'From validated prototype to multi-tenant, observable, billable production — without a rewrite.',
|
||||||
|
bullets: [
|
||||||
|
'Architecture & feature flags',
|
||||||
|
'Stripe billing & metering',
|
||||||
|
'Observability & SLOs',
|
||||||
|
],
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
title: 'AI Integration',
|
||||||
|
summary:
|
||||||
|
'Embed agents and LLM workflows into your product with evals, guardrails, and clear UX.',
|
||||||
|
bullets: [
|
||||||
|
'OpenAI · Anthropic · open models',
|
||||||
|
'RAG, evals, prompt caching',
|
||||||
|
'Streaming UX patterns',
|
||||||
|
],
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'platform',
|
||||||
|
title: 'Design Systems & Platform',
|
||||||
|
summary:
|
||||||
|
'Tokens, component library, CI, and the unglamorous foundations your team will compound on.',
|
||||||
|
bullets: [
|
||||||
|
'Figma → code parity',
|
||||||
|
'Storybook · CI · review tooling',
|
||||||
|
'Performance budgets',
|
||||||
|
],
|
||||||
|
icon: Cpu,
|
||||||
|
},
|
||||||
|
]
|
||||||
51
src/content/work.ts
Normal file
51
src/content/work.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface Work {
|
||||||
|
id: string
|
||||||
|
client: string
|
||||||
|
category: string
|
||||||
|
title: string
|
||||||
|
blurb: string
|
||||||
|
metrics: { label: string; value: string }[]
|
||||||
|
gradient: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const work: Work[] = [
|
||||||
|
{
|
||||||
|
id: 'lattice',
|
||||||
|
client: 'Lattice Health',
|
||||||
|
category: 'AI · Healthcare',
|
||||||
|
title: 'Clinical copilot for primary care.',
|
||||||
|
blurb:
|
||||||
|
'A streaming agent that drafts SOAP notes from ambient audio. Shipped to 800 clinicians in 11 weeks.',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Time saved / visit', value: '11 min' },
|
||||||
|
{ label: 'Adoption', value: '92%' },
|
||||||
|
],
|
||||||
|
gradient: 'from-violet-500/30 via-fuchsia-500/15 to-transparent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orbital',
|
||||||
|
client: 'Orbital Logistics',
|
||||||
|
category: 'SaaS · Operations',
|
||||||
|
title: 'Multi-tenant freight platform.',
|
||||||
|
blurb:
|
||||||
|
'Replatformed a Rails monolith to a typed, multi-tenant Postgres + NestJS stack with metered billing.',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'P95 latency', value: '−74%' },
|
||||||
|
{ label: 'ARR (yr 1)', value: '$4.2M' },
|
||||||
|
],
|
||||||
|
gradient: 'from-cyan-500/25 via-violet-500/15 to-transparent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beacon',
|
||||||
|
client: 'Beacon Studio',
|
||||||
|
category: 'Design System · Platform',
|
||||||
|
title: 'Design system + token pipeline.',
|
||||||
|
blurb:
|
||||||
|
'Figma variables → code in one source of truth. Deployed across 4 product lines with zero drift.',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Component reuse', value: '6.4×' },
|
||||||
|
{ label: 'Ship velocity', value: '+38%' },
|
||||||
|
],
|
||||||
|
gradient: 'from-emerald-400/20 via-cyan-500/15 to-transparent',
|
||||||
|
},
|
||||||
|
]
|
||||||
300
src/index.css
Normal file
300
src/index.css
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-bg: #0a0a0b;
|
||||||
|
--color-surface: #111114;
|
||||||
|
--color-surface-2: #16161b;
|
||||||
|
--color-hairline: #1f1f23;
|
||||||
|
--color-hairline-2: #2a2a30;
|
||||||
|
--color-text: #f5f5f7;
|
||||||
|
--color-muted: #8a8a92;
|
||||||
|
--color-muted-2: #5d5d66;
|
||||||
|
--color-violet: #7c5cff;
|
||||||
|
--color-violet-2: #a78bfa;
|
||||||
|
--color-cyan: #22d3ee;
|
||||||
|
|
||||||
|
--font-sans:
|
||||||
|
'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--font-display:
|
||||||
|
'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
sans-serif;
|
||||||
|
--font-mono:
|
||||||
|
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--radius-card: 1.25rem;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
border-color: var(--color-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: color-mix(in oklab, var(--color-violet) 35%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-violet);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lenis */
|
||||||
|
html.lenis,
|
||||||
|
html.lenis body {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lenis.lenis-smooth {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lenis.lenis-smooth [data-lenis-prevent] {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lenis.lenis-stopped {
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.container-x {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container-x {
|
||||||
|
padding-inline: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hairline {
|
||||||
|
border: 1px solid var(--color-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-hairline);
|
||||||
|
background-color: color-mix(in oklab, var(--color-surface) 80%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-violet);
|
||||||
|
box-shadow: 0 0 12px color-mix(in oklab, var(--color-violet) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, color-mix(in oklab, #ffffff 4%, transparent) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, color-mix(in oklab, #ffffff 4%, transparent) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
mask-image: radial-gradient(ellipse 90% 60% at 50% 35%, #000 35%, transparent 80%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 90% 60% at 50% 35%, #000 35%, transparent 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
120deg,
|
||||||
|
var(--color-text) 0%,
|
||||||
|
var(--color-text) 35%,
|
||||||
|
var(--color-violet-2) 65%,
|
||||||
|
var(--color-cyan) 100%
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 2.875rem;
|
||||||
|
padding-inline: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition:
|
||||||
|
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 200ms,
|
||||||
|
border-color 200ms,
|
||||||
|
color 200ms,
|
||||||
|
box-shadow 200ms;
|
||||||
|
will-change: transform;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-violet);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 0 color-mix(in oklab, #fff 20%, transparent) inset,
|
||||||
|
0 12px 28px -8px color-mix(in oklab, var(--color-violet) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: color-mix(in oklab, var(--color-violet) 92%, #fff 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-hairline-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: color-mix(in oklab, #fff 4%, transparent);
|
||||||
|
border-color: color-mix(in oklab, #fff 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
border: 1px solid var(--color-hairline);
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in oklab, var(--color-surface) 96%, transparent) 0%,
|
||||||
|
color-mix(in oklab, var(--color-bg) 92%, transparent) 100%
|
||||||
|
);
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in oklab, var(--color-violet) 40%, transparent),
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 250ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-glow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marquee-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
width: max-content;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-link {
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-link:hover::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glow-violet {
|
||||||
|
box-shadow: 0 0 80px -10px color-mix(in oklab, var(--color-violet) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.6'/></svg>");
|
||||||
|
opacity: 0.06;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/lib/cursor.ts
Normal file
48
src/lib/cursor.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useCursorBlob() {
|
||||||
|
useEffect(() => {
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const fine = window.matchMedia('(pointer: fine)').matches
|
||||||
|
if (reduced || !fine) return
|
||||||
|
|
||||||
|
const dot = document.createElement('div')
|
||||||
|
dot.setAttribute('aria-hidden', 'true')
|
||||||
|
dot.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 70%);
|
||||||
|
transform: translate3d(-100px, -100px, 0);
|
||||||
|
transition: transform 100ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
will-change: transform;
|
||||||
|
`
|
||||||
|
document.body.appendChild(dot)
|
||||||
|
|
||||||
|
let x = -100
|
||||||
|
let y = -100
|
||||||
|
function move(e: MouseEvent) {
|
||||||
|
x = e.clientX - 12
|
||||||
|
y = e.clientY - 12
|
||||||
|
dot.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||||
|
}
|
||||||
|
function leave() {
|
||||||
|
dot.style.transform = `translate3d(-100px, -100px, 0)`
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', move, { passive: true })
|
||||||
|
document.addEventListener('mouseleave', leave)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', move)
|
||||||
|
document.removeEventListener('mouseleave', leave)
|
||||||
|
dot.remove()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
53
src/lib/lenis.ts
Normal file
53
src/lib/lenis.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import Lenis from 'lenis'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
let singleton: Lenis | null = null
|
||||||
|
|
||||||
|
export function useLenis() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (reduced) return
|
||||||
|
|
||||||
|
const lenis = new Lenis({
|
||||||
|
duration: 1.1,
|
||||||
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
orientation: 'vertical',
|
||||||
|
gestureOrientation: 'vertical',
|
||||||
|
smoothWheel: true,
|
||||||
|
wheelMultiplier: 1,
|
||||||
|
touchMultiplier: 1.4,
|
||||||
|
})
|
||||||
|
singleton = lenis
|
||||||
|
|
||||||
|
function raf(time: number) {
|
||||||
|
lenis.raf(time)
|
||||||
|
ScrollTrigger.update()
|
||||||
|
rafId = requestAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
let rafId = requestAnimationFrame(raf)
|
||||||
|
|
||||||
|
document.documentElement.classList.add('lenis', 'lenis-smooth')
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
lenis.destroy()
|
||||||
|
document.documentElement.classList.remove('lenis', 'lenis-smooth')
|
||||||
|
singleton = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollToId(id: string) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
if (singleton) {
|
||||||
|
singleton.scrollTo(el, { offset: -72, duration: 1.1 })
|
||||||
|
} else {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/motion.ts
Normal file
23
src/lib/motion.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Variants } from 'framer-motion'
|
||||||
|
|
||||||
|
export const fadeUp: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 24 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: [0.2, 0.8, 0.2, 1] } },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fadeIn: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: { opacity: 1, transition: { duration: 0.6, ease: 'easeOut' } },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stagger: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: { staggerChildren: 0.08, delayChildren: 0.05 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scale: Variants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.96 },
|
||||||
|
visible: { opacity: 1, scale: 1, transition: { duration: 0.5, ease: [0.2, 0.8, 0.2, 1] } },
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
tsconfig.app.tsbuildinfo
Normal file
1
tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/About.tsx","./src/components/CTABand.tsx","./src/components/Capabilities.tsx","./src/components/FAQ.tsx","./src/components/Footer.tsx","./src/components/Hero.tsx","./src/components/Nav.tsx","./src/components/Process.tsx","./src/components/Services.tsx","./src/components/Work.tsx","./src/components/ui/Button.tsx","./src/components/ui/Counter.tsx","./src/components/ui/GradientOrb.tsx","./src/components/ui/GridBackground.tsx","./src/content/capabilities.ts","./src/content/faq.ts","./src/content/process.ts","./src/content/services.ts","./src/content/work.ts","./src/lib/cursor.ts","./src/lib/lenis.ts","./src/lib/motion.ts"],"version":"5.9.3"}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
sourcemap: false,
|
||||||
|
cssMinify: 'lightningcss',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
react: ['react', 'react-dom'],
|
||||||
|
gsap: ['gsap'],
|
||||||
|
motion: ['framer-motion'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user