├── .env.example
├── .gitignore
├── README.md
├── app
├── api
│ └── opengraph
│ │ └── route.tsx
├── crafts
│ └── [slug]
│ │ └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── not-found.tsx
└── page.tsx
├── bun.lockb
├── components
├── blur-overlay.tsx
├── clock.tsx
├── experience.tsx
├── experiment.tsx
├── experiments
│ ├── carousel.tsx
│ ├── component-wrapper.tsx
│ ├── dynamic-label.tsx
│ ├── filters.tsx
│ ├── folder.tsx
│ ├── order.tsx
│ ├── reaction.tsx
│ └── waitlist.tsx
├── project.tsx
├── section.tsx
└── social.tsx
├── content-collections.ts
├── content
└── crafts
│ ├── carousel.mdx
│ ├── dynamic-input-label.mdx
│ ├── filters.mdx
│ ├── folder.mdx
│ ├── order.mdx
│ ├── quick-reaction.mdx
│ └── waitlist.mdx
├── lib
├── hooks
│ └── use-media-query.ts
├── tinybird.ts
└── utils.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── public
├── crafts
│ ├── carousel.png
│ ├── dynamic-input-label.png
│ ├── filters.png
│ ├── folder.png
│ ├── order.png
│ ├── quick-reaction.png
│ └── waitlist.png
└── fonts
│ ├── Geist-Bold.ttf
│ ├── Geist-Medium.ttf
│ └── Geist-Regular.ttf
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | # Tinybird - Used for tracking visits
2 | TINYBIRD_API_KEY="your-api-key"
3 |
4 | # Seline - Used for analytics
5 | SELINE_TOKEN="your-token"
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env
35 |
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
44 | # content collections
45 | .content-collections
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/app/api/opengraph/route.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from "next/og";
2 | import { NextRequest } from "next/server";
3 |
4 | export const runtime = "edge";
5 |
6 | export async function GET(req: NextRequest) {
7 | try {
8 | const { searchParams } = new URL(req.url);
9 | const title = searchParams.get("title") ?? "Christo Todorov";
10 |
11 | // Load font
12 | const geistRegular = await fetch(
13 | new URL("../../../public/fonts/Geist-Regular.ttf", import.meta.url)
14 | ).then((res) => res.arrayBuffer());
15 |
16 | const geistMedium = await fetch(
17 | new URL("../../../public/fonts/Geist-Medium.ttf", import.meta.url)
18 | ).then((res) => res.arrayBuffer());
19 |
20 | return new ImageResponse(
21 | (
22 |
34 | {title !== "Christo Todorov" ? (
35 |
43 | Christo Todorov
44 |
45 | ) : (
46 |
47 | )}
48 |
58 | {title}
59 |
60 |
61 | ),
62 | {
63 | width: 1200,
64 | height: 630,
65 | fonts: [
66 | {
67 | name: "GeistRegular",
68 | data: geistRegular,
69 | style: "normal",
70 | },
71 | {
72 | name: "GeistMedium",
73 | data: geistMedium,
74 | style: "normal",
75 | },
76 | ],
77 | }
78 | );
79 | } catch (error: any) {
80 | console.error(error.message);
81 | return new Response(`Failed to generate the image`, {
82 | status: 500,
83 | });
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/app/crafts/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { allCrafts } from "content-collections";
2 | import { MDXContent } from "@content-collections/mdx/react";
3 | import DynamicInputLabel from "@/components/experiments/dynamic-label";
4 | import Carousel from "@/components/experiments/carousel";
5 | import Filters from "@/components/experiments/filters";
6 | import Folder from "@/components/experiments/folder";
7 | import Order from "@/components/experiments/order";
8 | import Reaction from "@/components/experiments/reaction";
9 | import Waitlist from "@/components/experiments/waitlist";
10 | import { notFound } from "next/navigation";
11 | import { Metadata } from "next/types";
12 |
13 | type Props = {
14 | params: Promise<{
15 | slug: string;
16 | }>;
17 | };
18 |
19 | export async function generateMetadata({ params }: Props): Promise {
20 | const { slug } = await params;
21 | const craft = allCrafts.find((craft) => craft.slug === slug);
22 |
23 | if (!craft) notFound();
24 |
25 | return {
26 | title: `Christo Todorov | ${craft.title}`,
27 | openGraph: {
28 | images: [
29 | {
30 | url: `/api/opengraph?title=${encodeURIComponent(craft.title)}`,
31 | alt: craft.title,
32 | },
33 | ],
34 | },
35 | };
36 | }
37 |
38 | const components = {
39 | Craft: {
40 | DynamicInputLabel: DynamicInputLabel,
41 | Carousel,
42 | Filters,
43 | Folder,
44 | Order,
45 | QuickReaction: Reaction,
46 | Waitlist,
47 | },
48 | };
49 |
50 | export default async function CraftPage({ params }: Props) {
51 | const { slug } = await params;
52 | const craft = allCrafts.find((craft) => craft.slug === slug);
53 |
54 | if (!craft?.mdx) {
55 | return notFound();
56 | }
57 |
58 | return (
59 |
60 |
61 |
{craft.title}
62 |
63 | {craft.date.toLocaleDateString()}
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin 'tailwindcss-animate';
4 |
5 | @variant dark (&:is(.dark *));
6 |
7 | @theme {
8 | --color-border: hsl(var(--border));
9 | --color-input: hsl(var(--input));
10 | --color-ring: hsl(var(--ring));
11 | --color-background: hsl(var(--background));
12 | --color-foreground: hsl(var(--foreground));
13 |
14 | --color-primary: hsl(var(--primary));
15 | --color-primary-foreground: hsl(var(--primary-foreground));
16 |
17 | --color-secondary: hsl(var(--secondary));
18 | --color-secondary-foreground: hsl(var(--secondary-foreground));
19 |
20 | --color-destructive: hsl(var(--destructive));
21 | --color-destructive-foreground: hsl(var(--destructive-foreground));
22 |
23 | --color-muted: hsl(var(--muted));
24 | --color-muted-foreground: hsl(var(--muted-foreground));
25 |
26 | --color-accent: hsl(var(--accent));
27 | --color-accent-foreground: hsl(var(--accent-foreground));
28 |
29 | --color-popover: hsl(var(--popover));
30 | --color-popover-foreground: hsl(var(--popover-foreground));
31 |
32 | --color-card: hsl(var(--card));
33 | --color-card-foreground: hsl(var(--card-foreground));
34 |
35 | --radius-lg: var(--radius);
36 | --radius-md: calc(var(--radius) - 2px);
37 | --radius-sm: calc(var(--radius) - 4px);
38 |
39 | --animate-accordion-down: accordion-down 0.2s ease-out;
40 | --animate-accordion-up: accordion-up 0.2s ease-out;
41 | --animate-enter: enter 1s both;
42 |
43 | --font-weight-thin: 150;
44 | --font-weight-extralight: 200;
45 | --font-weight-light: 250;
46 | --font-weight-normal: 350;
47 | --font-weight-medium: 450;
48 | --font-weight-semibold: 550;
49 | --font-weight-bold: 650;
50 | --font-weight-extrabold: 750;
51 | --font-weight-black: 850;
52 |
53 | @keyframes accordion-down {
54 | from {
55 | height: 0;
56 | }
57 | to {
58 | height: var(--radix-accordion-content-height);
59 | }
60 | }
61 | @keyframes accordion-up {
62 | from {
63 | height: var(--radix-accordion-content-height);
64 | }
65 | to {
66 | height: 0;
67 | }
68 | }
69 | @keyframes enter {
70 | from {
71 | opacity: 0;
72 | transform: translateY(0.5rem);
73 | }
74 | to {
75 | opacity: 1;
76 | transform: translateY(0);
77 | }
78 | }
79 | }
80 |
81 | @utility container {
82 | margin-inline: auto;
83 | padding-inline: 2rem;
84 | @media (width >= theme(--breakpoint-sm)) {
85 | max-width: none;
86 | }
87 | @media (width >= 1400px) {
88 | max-width: 1400px;
89 | }
90 | }
91 |
92 | /*
93 | The default border color has changed to `currentColor` in Tailwind CSS v4,
94 | so we've added these compatibility styles to make sure everything still
95 | looks the same as it did with Tailwind CSS v3.
96 |
97 | If we ever want to remove these styles, we need to add an explicit border
98 | color utility to any element that depends on these defaults.
99 | */
100 | @layer base {
101 | *,
102 | ::after,
103 | ::before,
104 | ::backdrop,
105 | ::file-selector-button {
106 | border-color: var(--color-gray-200, currentColor);
107 | }
108 | }
109 |
110 | @layer utilities {
111 | body {
112 | -webkit-font-smoothing: antialiased;
113 | -moz-osx-font-smoothing: grayscale;
114 | }
115 | }
116 |
117 | @layer base {
118 | :root {
119 | --background: 0 0% 100%;
120 | --foreground: 224 71.4% 4.1%;
121 |
122 | --muted: 0 0% 88%;
123 | --muted-foreground: 0 0% 58%;
124 |
125 | --popover: 0 0% 100%;
126 | --popover-foreground: 224 71.4% 4.1%;
127 |
128 | --card: 0 0% 100%;
129 | --card-foreground: 224 71.4% 4.1%;
130 |
131 | --border: 220 13% 91%;
132 | --input: 220 13% 91%;
133 |
134 | --primary: 0 0% 7%;
135 | --primary-foreground: 210 20% 98%;
136 |
137 | --secondary: 0 0% 96%;
138 | --secondary-foreground: 0 0% 30%;
139 |
140 | --accent: 220 14.3% 95.9%;
141 | --accent-foreground: 220.9 39.3% 11%;
142 |
143 | --destructive: 0 72.2% 50.6%;
144 | --destructive-foreground: 210 20% 98%;
145 |
146 | --ring: 220 13% 91%;
147 |
148 | --radius: 0.5rem;
149 | }
150 |
151 | .dark {
152 | --background: 0 0% 7%;
153 | --foreground: 0 0% 90%;
154 |
155 | --muted: 0 0% 30%;
156 | --muted-foreground: 0 0% 55%;
157 |
158 | --popover: 0 0% 7%;
159 | --popover-foreground: 210 20% 98%;
160 |
161 | --card: 0 0% 7%;
162 | --card-foreground: 210 20% 98%;
163 |
164 | --border: 0 0% 18%;
165 | --input: 0 0% 18%;
166 |
167 | --primary: 210 20% 98%;
168 | --primary-foreground: 220.9 39.3% 11%;
169 |
170 | --secondary: 0 0% 15%;
171 | --secondary-foreground: 0 0% 75%;
172 |
173 | --accent: 0 0% 18%;
174 | --accent-foreground: 210 20% 98%;
175 |
176 | --destructive: 355 100% 75%;
177 | --destructive-foreground: 210 20% 98%;
178 |
179 | --ring: 0 0% 40%;
180 | }
181 | }
182 |
183 | @layer base {
184 | * {
185 | @apply border-border font-normal;
186 | }
187 | body {
188 | @apply bg-background text-foreground;
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist, Geist_Mono } from "next/font/google";
3 | import { Analytics } from "@vercel/analytics/next";
4 | import "./globals.css";
5 | import Link from "next/link";
6 | import { getLastVisitor, logVisit } from "@/lib/tinybird";
7 | import { Clock } from "@/components/clock";
8 | import { BlurOverlay } from "@/components/blur-overlay";
9 | import Script from "next/script";
10 | import { getOrdinalSuffix } from "@/lib/utils";
11 |
12 | const geistSans = Geist({
13 | variable: "--font-geist-sans",
14 | subsets: ["latin"],
15 | });
16 |
17 | const geistMono = Geist_Mono({
18 | variable: "--font-geist-mono",
19 | subsets: ["latin"],
20 | });
21 |
22 | export const metadata: Metadata = {
23 | title: "Christo Todorov",
24 | description: "Design engineer based in Berlin, Germany.",
25 | openGraph: {
26 | images: [
27 | {
28 | url: "/api/opengraph",
29 | alt: "Christo Todorov",
30 | },
31 | ],
32 | },
33 | };
34 |
35 | export default async function RootLayout({
36 | children,
37 | }: Readonly<{
38 | children: React.ReactNode;
39 | }>) {
40 | logVisit();
41 | const lastVisitor = await getLastVisitor();
42 |
43 | return (
44 |
45 |
46 |
50 |
55 |
56 |
59 | {/* Vercel analytics */}
60 |
61 |
62 |
63 |
64 |
65 | Christo Todorov
66 |
67 |
68 |
69 | {children}
70 |
71 |
72 |
73 | Last visitor from {lastVisitor?.city},{" "}
74 | {lastVisitor?.country_region}, {lastVisitor?.country} (
75 | {lastVisitor?.total_visits}
76 | {getOrdinalSuffix(lastVisitor?.total_visits || 0)} view)
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import Link from "next/link";
3 |
4 | export const metadata: Metadata = {
5 | title: "Christo Todorov | 404",
6 | openGraph: {
7 | images: [
8 | {
9 | url: `/api/opengraph?title=${encodeURIComponent("404 - Not Found")}`,
10 | alt: "Christo Todorov",
11 | },
12 | ],
13 | },
14 | };
15 |
16 | export default function NotFound() {
17 | return (
18 |
19 |
Whoops! Could not find the page you were looking for.
20 |
24 | Go back home
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Experience from "@/components/experience";
2 | import Project from "@/components/project";
3 | import { Section } from "@/components/section";
4 | import { allCrafts } from "content-collections";
5 | import { Experiment } from "@/components/experiment";
6 | import Social from "@/components/social";
7 |
8 | export default function Home() {
9 | return (
10 | <>
11 | {/* About */}
12 |
13 |
14 | I am a designer & developer based in Berlin, Germany.
15 |
16 |
17 |
18 | Currently, I work as a design engineer at{" "}
19 |
25 | Superwall
26 | {" "}
27 | where I focus on delivering polished interfaces and creating refined
28 | web experiences with an emphasis on performance, accessibility, and
29 | attention to detail.
30 |
31 |
32 |
33 | Outside of work, I enjoy learning new skills, skiing, and building
34 | (mostly open source) software.
35 |
36 |
37 |
38 | {/* Experience */}
39 |
53 |
54 | {/* Projects */}
55 |
56 |
62 |
68 |
74 |
80 |
86 | Vercel's one-click deploy button for Supabase databases. Won{" "}
87 |
93 | Best Overall Project
94 | {" "}
95 | at the Launch Week X Hackathon.
96 | >
97 | }
98 | />
99 |
105 |
111 |
112 |
113 | {/* Experiments */}
114 |
115 |
116 |
117 | {allCrafts.map((craft, index) => (
118 |
124 | ))}
125 |
126 |
127 |
128 |
129 | {/* Contact */}
130 |
134 |
135 |
136 |
137 |
138 |
139 | >
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/bun.lockb
--------------------------------------------------------------------------------
/components/blur-overlay.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | export function BlurOverlay() {
6 | const [scrollY, setScrollY] = useState(0);
7 |
8 | useEffect(() => {
9 | const handleScroll = () => {
10 | setScrollY(window.scrollY);
11 | };
12 |
13 | window.addEventListener("scroll", handleScroll, { passive: true });
14 | return () => window.removeEventListener("scroll", handleScroll);
15 | }, []);
16 |
17 | const opacity = Math.min(scrollY / 150, 0.35);
18 | const maxBlur = Math.min(scrollY / 10, 3);
19 |
20 | return (
21 | <>
22 |
53 |
62 | >
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/clock.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
4 | import { useState, useEffect } from "react";
5 | import { cn } from "@/lib/utils";
6 |
7 | const style = {
8 | "--number-flow-char-height": "1em",
9 | } as React.CSSProperties;
10 |
11 | export function Clock() {
12 | const [time, setTime] = useState(new Date());
13 | const [isSafari, setIsSafari] = useState(false);
14 |
15 | useEffect(() => {
16 | // Safari detection
17 | setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
18 | }, []);
19 |
20 | useEffect(() => {
21 | const timer = setInterval(() => {
22 | const berlinTime = new Date().toLocaleString("en-US", {
23 | timeZone: "Europe/Berlin",
24 | });
25 | setTime(new Date(berlinTime));
26 | }, 1000);
27 | return () => clearInterval(timer);
28 | }, []);
29 |
30 | return (
31 |
32 |
40 |
45 |
50 | = 12 ? "PM" : "AM"} (UTC${
54 | time.getTimezoneOffset() / -60 >= 0 ? "+" : ""
55 | }${time.getTimezoneOffset() / -60})`}
56 | />
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/components/experience.tsx:
--------------------------------------------------------------------------------
1 | interface ExperienceProps {
2 | name: string;
3 | description: string;
4 | href?: string;
5 | period: {
6 | start: string;
7 | end: string;
8 | };
9 | }
10 |
11 | export default function Experience({
12 | name,
13 | description,
14 | href,
15 | period,
16 | }: ExperienceProps) {
17 | return (
18 |
19 |
20 |
{name}
21 |
22 | {period.start} - {period.end}
23 |
24 |
25 |
{description}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/components/experiment.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | interface ExperimentProps {
5 | image: string;
6 | path: string;
7 | index: number;
8 | }
9 |
10 | export const Experiment = ({ image, path, index }: ExperimentProps) => {
11 | const rotations = [-6, 0, 4, -4, 6, -2, 3];
12 | const rotation = rotations[index % rotations.length];
13 |
14 | return (
15 |
24 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/components/experiments/carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import ComponentWrapper from "./component-wrapper";
4 | import { useState } from "react";
5 | import { cn } from "@/lib/utils";
6 | import { motion, AnimationProps } from "framer-motion";
7 | import { GalleryVertical } from "lucide-react";
8 |
9 | const IMAGES = [
10 | "https://images.unsplash.com/photo-1692607519784-9e5406625d00?q=80&fm=jpg&w=1080&fit=max",
11 | "https://images.unsplash.com/photo-1715160441010-7d5050ec67a4?q=80&fm=jpg&w=1080&fit=max",
12 | "https://images.unsplash.com/photo-1715006020348-a4af9f36b664?q=80&fm=jpg&w=1080&fit=max",
13 | ];
14 |
15 | export default function Carousel() {
16 | const [focusedIndex, setFocusedIndex] = useState(
17 | Math.floor(IMAGES.length / 2)
18 | );
19 | const [orientation, setOrientation] = useState<"horizontal" | "vertical">(
20 | "horizontal"
21 | );
22 |
23 | const CENTER_INDEX = Math.floor(IMAGES.length / 2);
24 |
25 | const handleTranslate = (index: number) => {
26 | // If the index is lower than center index, move the images up
27 | if (index < CENTER_INDEX) {
28 | return `+${(CENTER_INDEX - index) * 200}px`;
29 | }
30 |
31 | // If the index is greater than center index, move the images down
32 | if (index > CENTER_INDEX) {
33 | return `-${(index - CENTER_INDEX) * 200}px`;
34 | }
35 |
36 | return "0";
37 | };
38 |
39 | const variants: Record = {
40 | Carousel: {
41 | animate: {
42 | transform:
43 | orientation === "horizontal"
44 | ? `translateX(${handleTranslate(focusedIndex)})`
45 | : `translateY(${handleTranslate(focusedIndex)})`,
46 | },
47 | },
48 | Image: {
49 | focused: {
50 | scale: 1.1,
51 | opacity: 1,
52 | },
53 | notFocused: {
54 | scale: 0.85,
55 | opacity: 0.5,
56 | },
57 | },
58 | Indicator: {
59 | focused: {
60 | height: orientation === "horizontal" ? "0.625rem" : "1.25rem",
61 | width: orientation === "horizontal" ? "1.25rem" : "0.625rem",
62 | },
63 | notFocused: {
64 | height: orientation === "horizontal" ? "0.625rem" : "0.625rem",
65 | },
66 | },
67 | };
68 |
69 | return (
70 |
71 | {/* Carousel */}
72 |
73 |
81 | {IMAGES.map((image, index) => (
82 | setFocusedIndex(index)}
90 | className={cn(
91 | "rounded-sm h-[200px] min-w-[200px] w-[200px] select-none",
92 | focusedIndex !== index && "cursor-pointer",
93 | orientation === "horizontal" &&
94 | focusedIndex !== index &&
95 | focusedIndex !== CENTER_INDEX &&
96 | (focusedIndex - 1 !== index || focusedIndex + 1 !== index) &&
97 | (focusedIndex > CENTER_INDEX ? "-ml-[24px]" : "-mr-[24px]")
98 | )}
99 | />
100 | ))}
101 |
102 |
103 |
104 | {/* Indicator */}
105 |
113 | {IMAGES.map((_, index) => (
114 | setFocusedIndex(index)}
119 | className={cn(
120 | "w-2.5 bg-muted/60 rounded-full cursor-pointer transition-colors",
121 | focusedIndex === index && "bg-muted"
122 | )}
123 | />
124 | ))}
125 |
126 |
127 | {/* Action buttons */}
128 |
129 | {
132 | if (orientation === "vertical") {
133 | setOrientation("horizontal");
134 | } else {
135 | setOrientation("vertical");
136 | }
137 | }}
138 | >
139 |
145 |
146 |
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/components/experiments/component-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { HTMLProps } from "react";
3 |
4 | export default function ComponentWrapper({
5 | children,
6 | className,
7 | ...props
8 | }: HTMLProps & { children: React.ReactNode }) {
9 | return (
10 |
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/experiments/dynamic-label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import ComponentWrapper from "./component-wrapper";
5 | import { AnimationProps, motion } from "framer-motion";
6 | import { useState } from "react";
7 |
8 | export default function DynamicInputLabel() {
9 | const [value, setValue] = useState("");
10 | const [animate, setAnimate] = useState<"rest" | "focus">("rest");
11 |
12 | const variants: Record = {
13 | label: {
14 | rest: {
15 | opacity: 1,
16 | left: 0,
17 | top: 0,
18 | color: "hsl(var(--muted-foreground))",
19 | },
20 | focus: {
21 | opacity: 1,
22 | left: -10,
23 | top: -32,
24 | color: "hsl(var(--secondary-foreground))",
25 | },
26 | },
27 | placeholder: {
28 | initial: {
29 | opacity: 0,
30 | },
31 | rest: (value: string) => ({
32 | opacity: 0,
33 | transition: {
34 | duration: value ? 0.05 : 0.15,
35 | },
36 | }),
37 | focus: {
38 | opacity: 1,
39 | transition: {
40 | duration: 0.2,
41 | delay: 0.05,
42 | },
43 | },
44 | },
45 | };
46 |
47 | return (
48 |
49 |
50 | {/* Input */}
51 | setAnimate("focus")}
57 | onBlur={() => setAnimate("rest")}
58 | value={value}
59 | onChange={(e) => setValue(e.target.value)}
60 | />
61 |
62 | {/* Label & Placeholder */}
63 |
71 | Email
72 |
73 |
80 | example@gmail.com
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/components/experiments/filters.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCallback, useState } from "react";
4 | import ComponentWrapper from "./component-wrapper";
5 | import { cn } from "@/lib/utils";
6 | import { AnimationProps, MotionProps, motion } from "framer-motion";
7 |
8 | type FilterItemProps = {
9 | children: React.ReactNode;
10 | active?: boolean;
11 | isBound: "left" | "center" | "single";
12 | } & Omit, "type"> &
13 | MotionProps;
14 |
15 | function FilterItem({
16 | children,
17 | active,
18 | className,
19 | isBound,
20 | ...props
21 | }: FilterItemProps) {
22 | const variants: AnimationProps["variants"] = {
23 | left: {
24 | borderTopLeftRadius: "0px",
25 | borderBottomLeftRadius: "0px",
26 | borderTopRightRadius: "9999px",
27 | borderBottomRightRadius: "9999px",
28 | marginLeft: "-14px",
29 | paddingLeft: "16px",
30 | },
31 | center: {
32 | borderTopLeftRadius: "0px",
33 | borderBottomLeftRadius: "0px",
34 | borderTopRightRadius: "0px",
35 | borderBottomRightRadius: "0px",
36 | borderRightColor: "transparent",
37 | borderLeftColor: "transparent",
38 | marginLeft: "-14px",
39 | paddingLeft: "16px",
40 | },
41 | single: {
42 | borderRadius: "9999px",
43 | borderRightColor: "inherit",
44 | borderLeftColor: "inherit",
45 | marginLeft: "12px",
46 | paddingLeft: "8px",
47 | },
48 | };
49 |
50 | return (
51 |
66 | {children}
67 |
68 | );
69 | }
70 |
71 | export default function Filters() {
72 | const [filters, setFilters] = useState>({
73 | Playlists: false,
74 | Albums: false,
75 | Liked: false,
76 | Artists: false,
77 | Downloaded: false,
78 | });
79 |
80 | const isBound = useCallback(
81 | (filter: string): "left" | "center" | "single" => {
82 | // Get the current index of the filter.
83 | const filterIndex = Object.keys(filters).indexOf(filter);
84 |
85 | // If filter is not active, return none.
86 | if (!filters[filter]) return "single";
87 |
88 | // Get the previous and next filters.
89 | const prevFilter = Object.keys(filters)[filterIndex - 1];
90 | const nextFilter = Object.keys(filters)[filterIndex + 1];
91 |
92 | // If both previous and next filters are active, return center.
93 | if (filters[prevFilter] && filters[nextFilter]) return "center";
94 |
95 | // If previous filter is active, return left.
96 | if (filters[prevFilter]) return "left";
97 |
98 | return "single";
99 | },
100 | [filters]
101 | );
102 |
103 | return (
104 |
105 |
106 | {Object.entries(filters).map(([filter, active], index) => (
107 | {
116 | setFilters((prev) => ({ ...prev, [filter]: !prev[filter] }));
117 | }}
118 | >
119 | {filter}
120 |
121 | ))}
122 |
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/components/experiments/folder.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // Inspired by Sam (https://twitter.com/samdape/status/1786077609110946056)
4 | import { cn } from "@/lib/utils";
5 | import { AnimationProps, motion } from "framer-motion";
6 | import Image from "next/image";
7 | import { useState } from "react";
8 | import ComponentWrapper from "./component-wrapper";
9 | import useMediaQuery from "@/lib/hooks/use-media-query";
10 |
11 | const IMAGES = [
12 | "https://images.unsplash.com/photo-1715117022094-c65019842057?q=80&fm=jpg&w=1080&fit=max",
13 | "https://images.unsplash.com/photo-1715160441010-7d5050ec67a4?q=80&fm=jpg&w=1080&fit=max",
14 | "https://images.unsplash.com/photo-1715006020348-a4af9f36b664?q=80&fm=jpg&w=1080&fit=max",
15 | ];
16 |
17 | const animationVariants: Record = {
18 | folderBack: {
19 | hover: {
20 | translateY: "0.5rem",
21 | },
22 | },
23 | folderFrontLeft: {
24 | hover: {
25 | skewX: "3deg",
26 | translateX: "-0.25rem",
27 | translateY: "2px",
28 | },
29 | clicked: {
30 | skewX: "0deg",
31 | translateX: "0rem",
32 | translateY: "0rem",
33 | transition: {
34 | delay: 0.15,
35 | },
36 | },
37 | },
38 | folderFrontRight: {
39 | hover: {
40 | skewX: "-3deg",
41 | translateX: "0.25rem",
42 | translateY: "2px",
43 | },
44 | clicked: {
45 | skewX: "0deg",
46 | translateX: "0rem",
47 | translateY: "0rem",
48 | transition: {
49 | delay: 0.15,
50 | },
51 | },
52 | },
53 | folderContent: {
54 | hover: {
55 | translateY: "-1.5rem",
56 | rotate: "-10deg",
57 | },
58 | rest: {
59 | translateY: "1.5rem",
60 | },
61 | clicked: {
62 | translateY: "0rem",
63 | scale: 1.4,
64 | },
65 | },
66 | folderContainer: {
67 | clicked: {
68 | translateY: "11rem",
69 | scale: 1.4,
70 | transition: {
71 | delay: 0.1,
72 | type: "tween",
73 | ease: "easeOut",
74 | duration: 0.25,
75 | },
76 | },
77 | rest: {
78 | translateY: "0rem",
79 | scale: 1,
80 | },
81 | },
82 | folderLabel: {
83 | hover: {
84 | translateY: "0.15rem",
85 | },
86 | },
87 | };
88 |
89 | function FolderFrontStrips() {
90 | return (
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
98 | export default function Folder() {
99 | const [animate, setAnimate] = useState<"clicked" | "rest" | "hover">("rest");
100 | const { isDesktop } = useMediaQuery();
101 |
102 | return (
103 |
104 | {
111 | if (!isDesktop) {
112 | if (animate === "rest") {
113 | setAnimate("hover");
114 | } else if (animate === "hover") {
115 | setAnimate("clicked");
116 | } else {
117 | setAnimate("rest");
118 | }
119 | } else {
120 | setAnimate((prev) => (prev === "rest" ? "clicked" : "rest"));
121 | }
122 | }}
123 | >
124 | {/* Folder Back */}
125 |
129 | {/* Bumb on top */}
130 |
134 | {/* Folder Front */}
135 |
136 |
140 |
141 |
142 |
146 |
147 |
148 |
149 |
150 | {/* Shadow */}
151 |
152 |
153 | {/* Content */}
154 |
159 | {IMAGES.slice(0, 3).map((image, i) => (
160 |
202 |
208 |
209 | ))}
210 |
211 |
212 | {/* Label */}
213 |
217 | Images
218 |
219 | 3 items
220 |
221 |
222 |
223 | );
224 | }
225 |
--------------------------------------------------------------------------------
/components/experiments/order.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import ComponentWrapper from "./component-wrapper";
5 | import { AnimationProps, motion } from "framer-motion";
6 | import { cn } from "@/lib/utils";
7 | import { CheckCircle, Loader } from "@geist-ui/icons";
8 |
9 | const STEP_DURATION = 2000;
10 | const STEP_DELAY = 1250;
11 |
12 | const STEPS = [
13 | {
14 | label: "Stock",
15 | message: "Checking",
16 | success: "Available",
17 | },
18 | {
19 | label: "Payment",
20 | message: "Processing",
21 | success: "Received",
22 | },
23 | {
24 | label: "Delivery",
25 | message: "Preparing",
26 | success: "Shipped",
27 | },
28 | ];
29 |
30 | const LABELS = {
31 | initial: "Order",
32 | success: "Order Placed",
33 | };
34 |
35 | type AnimationType = "hover" | "rest" | "click" | "complete";
36 |
37 | // Step Component
38 | function Step({
39 | currentStep,
40 | loading,
41 | active,
42 | data,
43 | }: {
44 | currentStep: number;
45 | loading: boolean;
46 | active: boolean;
47 | data: { label: string; message: string; success: string };
48 | }) {
49 | return (
50 | = 0 ? 1 : 0,
61 | transition: {
62 | delay: 0.15,
63 | },
64 | },
65 | }}
66 | >
67 |
68 | {data.label}
69 |
70 |
76 | {loading ? data.message : data.success}
77 |
78 |
79 | );
80 | }
81 |
82 | export default function Order() {
83 | const [animate, setAnimate] = useState("rest");
84 | const [step, setStep] = useState(0);
85 | const [loading, setLoading] = useState(false);
86 | const [buttonLabel, setButtonLabel] = useState(LABELS.initial);
87 |
88 | const animationVariants: Record = {
89 | buttonContainer: {
90 | click: {
91 | width: "13rem",
92 | height: `${36 + 21 * (step + 1)}px`,
93 | justifyContent: "start",
94 | transition: {
95 | type: "spring",
96 | stiffness: 120,
97 | damping: 15,
98 | },
99 | },
100 | complete: {
101 | width: "9rem",
102 | height: "2.25rem",
103 | backgroundColor: "#4ade80",
104 | color: "#fff",
105 | transition: {
106 | type: "spring",
107 | stiffness: 120,
108 | damping: 15,
109 | },
110 | },
111 | rest: {
112 | width: "4.5rem",
113 | height: "2.25rem",
114 | transition: {
115 | type: "spring",
116 | stiffness: 140,
117 | damping: 20,
118 | },
119 | },
120 | },
121 | buttonHeader: {
122 | click: {
123 | scale: 0.9,
124 | position: "absolute",
125 | justifyContent: "space-between",
126 | top: "0.3rem",
127 | transition: {
128 | type: "spring",
129 | stiffness: 120,
130 | damping: 15,
131 | },
132 | },
133 | complete: {
134 | scale: 1,
135 | position: "absolute",
136 | justifyContent: "space-evenly",
137 | top: "16%",
138 | },
139 | },
140 | };
141 |
142 | useEffect(() => {
143 | if (animate === "click") {
144 | const interval = setInterval(() => {
145 | setStep((prev) => {
146 | if (prev < STEPS.length - 1) {
147 | return prev + 1;
148 | }
149 | return prev;
150 | });
151 | }, STEP_DURATION);
152 |
153 | // Clean up the interval when the component unmounts or animate changes
154 | return () => {
155 | clearInterval(interval);
156 | };
157 | }
158 |
159 | if (animate === "complete") {
160 | setButtonLabel(LABELS.success);
161 | setTimeout(() => {
162 | setStep(0);
163 | setButtonLabel(LABELS.initial);
164 | setAnimate("rest");
165 | }, STEP_DELAY);
166 | }
167 | }, [animate]);
168 |
169 | // on step change, set loading to true for 1s
170 | useEffect(() => {
171 | if (animate === "click") {
172 | setLoading(true);
173 | setTimeout(() => {
174 | setLoading(false);
175 | }, STEP_DELAY * 1.5);
176 | }
177 | }, [step, animate]);
178 |
179 | return (
180 |
181 | {
185 | setAnimate("click");
186 |
187 | setTimeout(() => {
188 | setAnimate("complete");
189 | setStep(0);
190 | }, STEP_DURATION * STEPS.length + 200);
191 | }}
192 | variants={animationVariants.buttonContainer}
193 | className={cn(
194 | "flex relative items-center flex-col h-9 w-[4.5rem] justify-center text-primary-foreground bg-primary rounded-xl cursor-pointer active:scale-95 disabled:active:scale-100 transition-transform disabled:cursor-progress"
195 | )}
196 | disabled={animate !== "rest"}
197 | >
198 | {/* Header */}
199 |
203 |
204 |
210 | {buttonLabel}
211 |
212 |
219 |
225 |
231 |
232 |
233 |
234 | {/* Steps */}
235 |
241 | {STEPS.map((data, i) => (
242 | = i}
247 | data={data}
248 | />
249 | ))}
250 |
251 |
252 |
253 | );
254 | }
255 |
--------------------------------------------------------------------------------
/components/experiments/reaction.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef, useState, useCallback, useEffect } from "react";
4 | import ComponentWrapper from "./component-wrapper";
5 | import { cn } from "@/lib/utils";
6 | import { AnimationProps, motion } from "framer-motion";
7 |
8 | interface IconItemProps extends React.ButtonHTMLAttributes {
9 | icon: string;
10 | selected?: boolean;
11 | }
12 |
13 | interface SpawnedIcon {
14 | x: number;
15 | y: number;
16 | icon: string;
17 | key: number;
18 | index: number;
19 | yOffset: number;
20 | }
21 |
22 | function IconItem({ icon, selected, ...props }: IconItemProps) {
23 | return (
24 |
32 | {icon}
33 |
34 | );
35 | }
36 |
37 | export default function QuickReaction() {
38 | const icons = ["👍", "👎", "❤️", "😂", "🤩"];
39 | const SPAWN_AMOUNT = 4;
40 |
41 | const [selected, setSelected] = useState(icons[0]);
42 | const [spawnedDivs, setSpawnedDivs] = useState([]);
43 | const containerRef = useRef(null);
44 | const lastTap = useRef(0);
45 |
46 | // spawn icons on double tap
47 | const handleDoubleTap = useCallback(
48 | (event: React.MouseEvent) => {
49 | const rect = containerRef.current?.getBoundingClientRect();
50 | if (rect) {
51 | const x = event.clientX - rect.left;
52 | const y = event.clientY - rect.top;
53 | const newDivs: SpawnedIcon[] = [];
54 |
55 | for (let i = 0; i < SPAWN_AMOUNT; i++) {
56 | const newDiv: SpawnedIcon = {
57 | x: x - 10,
58 | y: y - 10,
59 | icon: selected,
60 | key: Date.now() + i,
61 | index: i,
62 | // random offset between -10 and 10
63 | yOffset: Math.random() * 20 - 10,
64 | };
65 | newDivs.push(newDiv);
66 | }
67 |
68 | setSpawnedDivs((prev) => [...prev, ...newDivs]);
69 | }
70 | },
71 | [selected]
72 | );
73 |
74 | // validate if double tap
75 | const handleTap = useCallback(
76 | (event: React.MouseEvent) => {
77 | const now = Date.now();
78 | const DOUBLE_TAP_DELAY = 300;
79 |
80 | if (now - lastTap.current < DOUBLE_TAP_DELAY) {
81 | handleDoubleTap(event);
82 | }
83 |
84 | lastTap.current = now;
85 | },
86 | [handleDoubleTap]
87 | );
88 |
89 | useEffect(() => {
90 | const timer = setTimeout(() => {
91 | setSpawnedDivs((prev) => prev.slice(SPAWN_AMOUNT));
92 | }, 2500);
93 |
94 | return () => clearTimeout(timer);
95 | }, [spawnedDivs]);
96 |
97 | // icon variants
98 | const iconVariants: AnimationProps["variants"] = {
99 | initial: (icon: SpawnedIcon) => ({
100 | scale: 0,
101 | rotate: Math.random() * 60 - 30,
102 | x: icon.x,
103 | y: icon.y,
104 | }),
105 | animate: (icon: SpawnedIcon) => ({
106 | scale: [0, 1.25],
107 | x: [icon.x, icon.x + (icon.index - 1) * 40],
108 | y: [icon.y, icon.y + 32 + icon.yOffset, icon.y - 300 + icon.yOffset],
109 | transition: {
110 | scale: {
111 | duration: 1,
112 | ease: "easeInOut",
113 | },
114 | default: {
115 | duration: 1.5,
116 | times: [0, 0.3, 1],
117 | ease: "easeInOut",
118 | },
119 | },
120 | }),
121 | };
122 |
123 | // cursor variants
124 | const cursorVariants: AnimationProps["variants"] = {
125 | animate: {
126 | left: `${spawnedDivs[spawnedDivs.length - 1]?.x}px`,
127 | top: `${spawnedDivs[spawnedDivs.length - 1]?.y}px`,
128 | scale: [1, 1, 0],
129 | opacity: 1,
130 | transition: {
131 | duration: 1,
132 | times: [0, 0.9, 1],
133 | ease: "easeInOut",
134 | },
135 | },
136 | rest: {
137 | left: `${spawnedDivs[spawnedDivs.length - 1]?.x}px`,
138 | top: `${spawnedDivs[spawnedDivs.length - 1]?.y}px`,
139 | scale: 0,
140 | opacity: 1,
141 | },
142 | };
143 |
144 | return (
145 |
146 |
151 | Double tap
152 | to react
153 | {spawnedDivs.map((div) => (
154 |
170 | {div.icon}
171 |
172 | ))}
173 |
184 |
185 |
186 |
187 | {icons.map((icon) => (
188 | setSelected(icon)}
193 | />
194 | ))}
195 |
196 |
197 | );
198 | }
199 |
--------------------------------------------------------------------------------
/components/experiments/waitlist.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion, AnimationProps } from "framer-motion";
4 | import ComponentWrapper from "./component-wrapper";
5 | import { useState } from "react";
6 | import { CheckCircle, Loader } from "@geist-ui/icons";
7 | import { cn } from "@/lib/utils";
8 |
9 | type AnimationType = "hover" | "rest" | "click" | "complete";
10 |
11 | export default function Waitlist() {
12 | const [animate, setAnimate] = useState("rest");
13 | const [isLoading, setIsLoading] = useState(false);
14 |
15 | const animationVariants: Record = {
16 | button: {
17 | rest: {
18 | scale: 1,
19 | },
20 | click: {
21 | scale: 0.8,
22 | transition: {
23 | type: "tween",
24 | duration: 0.2,
25 | },
26 | },
27 | complete: {
28 | scale: 1,
29 | width: "10.75rem",
30 | },
31 | },
32 | input: {
33 | rest: {
34 | scale: 0,
35 | },
36 | click: {
37 | opacity: 1,
38 | scale: 1,
39 | transition: {
40 | type: "tween",
41 | duration: 0.2,
42 | },
43 | },
44 | complete: {
45 | scale: 0,
46 | },
47 | },
48 | inputContainer: {
49 | rest: {
50 | width: "5rem",
51 | },
52 | click: {
53 | width: "15rem",
54 | transition: {
55 | type: "spring",
56 | stiffness: 150,
57 | damping: 20,
58 | },
59 | },
60 | complete: {
61 | width: "6.75rem",
62 | },
63 | },
64 | buttonLabel: {
65 | rest: {
66 | translateY: "1.25rem",
67 | transition: {
68 | type: "spring",
69 | duration: 0.65,
70 | },
71 | },
72 | click: {
73 | translateY: "1.25rem",
74 | },
75 | complete: {
76 | translateY: "-1.25rem",
77 | transition: {
78 | type: "spring",
79 | duration: 0.65,
80 | },
81 | },
82 | },
83 | };
84 |
85 | // Timeout functoin to reset animation on complete
86 | function resetAnimation() {
87 | setTimeout(() => {
88 | setAnimate("rest");
89 | }, 2000);
90 | }
91 |
92 | return (
93 |
94 | ) => {
100 | e.preventDefault();
101 | e.stopPropagation();
102 |
103 | // Simulate loading
104 | setIsLoading(true);
105 | setTimeout(() => {
106 | setAnimate("complete");
107 | setIsLoading(false);
108 | resetAnimation();
109 | (e.target as HTMLFormElement).reset();
110 | }, 2000);
111 | }}
112 | initial={false}
113 | >
114 | {/* Button */}
115 | {
127 | if (animate === "click") {
128 | return;
129 | }
130 | e.preventDefault();
131 | e.stopPropagation();
132 | setAnimate("click");
133 | }}
134 | type={animate === "click" ? "submit" : "button"}
135 | disabled={isLoading || animate === "complete"}
136 | >
137 |
141 |
142 |
148 |
154 | Sign up
155 |
156 |
157 |
158 |
159 | You're in
160 |
161 |
162 |
163 |
164 | {/* Input */}
165 |
176 |
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/components/project.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRight } from "lucide-react";
2 | import Link from "next/link";
3 | import { ReactNode } from "react";
4 |
5 | interface ProjectProps {
6 | name: string;
7 | description: string | ReactNode;
8 | href: string;
9 | year: number;
10 | }
11 |
12 | export default function Project({
13 | name,
14 | description,
15 | href,
16 | year,
17 | }: ProjectProps) {
18 | return (
19 |
20 |
21 |
27 | {name}
28 |
29 |
30 |
•
31 |
{year}
32 |
33 |
{description}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/section.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | interface SectionProps {
4 | title: string;
5 | children: React.ReactNode;
6 | className?: string;
7 | }
8 |
9 | export const Section = ({ title, children, className }: SectionProps) => {
10 | return (
11 |
12 | {title}
13 | {children}
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/components/social.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRight } from "lucide-react";
2 |
3 | export default function Social({ name, url }: { name: string; url: string }) {
4 | return (
5 |
11 | {name}
12 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/content-collections.ts:
--------------------------------------------------------------------------------
1 | import { defineCollection, defineConfig } from "@content-collections/core";
2 | import { compileMDX } from "@content-collections/mdx";
3 |
4 | // for more information on configuration, visit:
5 | // https://www.content-collections.dev/docs/configuration
6 |
7 | const crafts = defineCollection({
8 | name: "crafts",
9 | directory: "content/crafts",
10 | include: "*.mdx",
11 | schema: (z) => ({
12 | title: z.string(),
13 | slug: z.string(),
14 | description: z.string(),
15 | date: z.coerce.date(),
16 | }),
17 | transform: async (document, context) => {
18 | const mdx = await compileMDX(context, document);
19 | return {
20 | ...document,
21 | mdx,
22 | };
23 | },
24 | });
25 |
26 | export default defineConfig({
27 | collections: [crafts],
28 | });
29 |
--------------------------------------------------------------------------------
/content/crafts/carousel.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Carousel
3 | slug: carousel
4 | description: Vertical & horizontal image carousel with indicators.
5 | date: 2024-07-08
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/content/crafts/dynamic-input-label.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Dynamic Input Label
3 | description: Small concept for a dynamic input label/placeholder.
4 | date: 2024-07-12
5 | slug: dynamic-input-label
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/content/crafts/filters.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Filters
3 | description: Spotify-inspired filter group animation.
4 | date: 2024-07-05
5 | slug: filters
6 | ---
7 |
8 | Spotify-inspired filter group animation.
9 |
10 |
11 |
--------------------------------------------------------------------------------
/content/crafts/folder.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Folder
3 | description: An animated MacOS folder exploration.
4 | date: 2024-05-12
5 | slug: folder
6 | ---
7 |
8 | An animated MacOS folder exploration.
9 |
10 |
11 |
12 | Thanks to Sam for the initial [idea and inspiration](https://twitter.com/samdape/status/1786077609110946056).
13 |
--------------------------------------------------------------------------------
/content/crafts/order.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Order
3 | description: A dynamic order button with status updates.
4 | date: 2024-05-15
5 | slug: order
6 | ---
7 |
8 | An order button with dynamic status updates for a fictional product.
9 |
10 |
11 |
--------------------------------------------------------------------------------
/content/crafts/quick-reaction.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Quick Reaction
3 | description: Quick reaction icon animation.
4 | date: 2024-07-09
5 | slug: quick-reaction
6 | ---
7 |
8 | Inspired by the quick reaction interaction from [Family](https://family.co/) & [Honk](https://honk.me/).
9 |
10 |
11 |
--------------------------------------------------------------------------------
/content/crafts/waitlist.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Waitlist
3 | description: Inline animated waitlist input.
4 | date: 2024-05-22
5 | slug: waitlist
6 | ---
7 |
8 | An inline animated waitlist signup button expanding to a form.
9 |
10 |
11 |
--------------------------------------------------------------------------------
/lib/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | export default function useMediaQuery() {
6 | const [isPWA, setIsPWA] = useState(false);
7 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>(
8 | null
9 | );
10 | const [dimensions, setDimensions] = useState<{
11 | width: number;
12 | height: number;
13 | } | null>(null);
14 |
15 | useEffect(() => {
16 | // Check if PWA
17 | if (window.matchMedia("(display-mode: standalone)").matches) {
18 | setIsPWA(true);
19 | }
20 |
21 | // Check device
22 | const checkDevice = () => {
23 | if (window.matchMedia("(max-width: 640px)").matches) {
24 | setDevice("mobile");
25 | } else if (
26 | window.matchMedia("(min-width: 641px) and (max-width: 1024px)").matches
27 | ) {
28 | setDevice("tablet");
29 | } else {
30 | setDevice("desktop");
31 | }
32 | setDimensions({ width: window.innerWidth, height: window.innerHeight });
33 | };
34 |
35 | // Initial detection
36 | checkDevice();
37 |
38 | // Listener for windows resize
39 | window.addEventListener("resize", checkDevice);
40 |
41 | // Cleanup listener
42 | return () => {
43 | window.removeEventListener("resize", checkDevice);
44 | };
45 | }, []);
46 |
47 | return {
48 | device,
49 | width: dimensions?.width,
50 | height: dimensions?.height,
51 | isMobile: device === "mobile",
52 | isTablet: device === "tablet",
53 | isDesktop: device === "desktop",
54 | isPWA,
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/lib/tinybird.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 |
3 | export async function logVisit() {
4 | if (!process.env.VERCEL) {
5 | return console.log("[!] Not on Vercel, skipping log visit");
6 | }
7 |
8 | const headersList = await headers();
9 |
10 | const response = await fetch(
11 | "https://api.tinybird.co/v0/events?name=visits",
12 | {
13 | method: "POST",
14 | headers: {
15 | Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
16 | },
17 | body: JSON.stringify({
18 | timestamp: new Date().toISOString(),
19 | country: decodeURIComponent(
20 | headersList.get("X-Vercel-IP-Country") ?? "Unknown"
21 | ),
22 | country_region: decodeURIComponent(
23 | headersList.get("X-Vercel-IP-Country-Region") ?? "Unknown"
24 | ),
25 | city: decodeURIComponent(
26 | headersList.get("X-Vercel-IP-City") ?? "Unknown"
27 | ),
28 | }),
29 | }
30 | );
31 |
32 | if (!response.ok) {
33 | console.error("[!] Failed to log visit:", response);
34 | }
35 | }
36 |
37 | export async function getLastVisitor() {
38 | const response = await fetch(
39 | `https://api.tinybird.co/v0/pipes/get_last_visit.json?token=${process.env.TINYBIRD_API_KEY}`
40 | );
41 |
42 | if (!response.ok) {
43 | console.error("[!] Failed to get last visitor:", response);
44 | return null;
45 | }
46 |
47 | const data = await response.json();
48 |
49 | return data.data[0] as {
50 | country: string;
51 | country_region: string;
52 | city: string;
53 | timestamp: string;
54 | total_visits: number;
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function getOrdinalSuffix(n: number): string {
9 | const j = n % 10;
10 | const k = n % 100;
11 | if (j == 1 && k != 11) return "st";
12 | if (j == 2 && k != 12) return "nd";
13 | if (j == 3 && k != 13) return "rd";
14 | return "th";
15 | }
16 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import { withContentCollections } from "@content-collections/next";
2 | import type { NextConfig } from "next";
3 |
4 | const nextConfig: NextConfig = {
5 | images: {
6 | remotePatterns: [
7 | {
8 | protocol: "https",
9 | hostname: "images.unsplash.com",
10 | pathname: "/**",
11 | },
12 | ],
13 | },
14 | };
15 |
16 | export default withContentCollections(nextConfig);
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@geist-ui/icons": "^1.0.2",
13 | "@number-flow/react": "^0.4.4",
14 | "@vercel/analytics": "^1.5.0",
15 | "clsx": "^2.1.1",
16 | "framer-motion": "^11.15.0",
17 | "lucide-react": "^0.469.0",
18 | "next": "15.1.2",
19 | "react": "^19.0.0",
20 | "react-dom": "^19.0.0",
21 | "tailwind-merge": "^2.6.0",
22 | "tailwindcss-animate": "^1.0.7"
23 | },
24 | "devDependencies": {
25 | "@content-collections/core": "^0.8.0",
26 | "@content-collections/mdx": "^0.2.0",
27 | "@content-collections/next": "^0.2.4",
28 | "@tailwindcss/postcss": "^4.0.0-beta.8",
29 | "@types/node": "^20",
30 | "@types/react": "^19",
31 | "@types/react-dom": "^19",
32 | "postcss": "^8",
33 | "tailwindcss": "^4.0.0-beta.8",
34 | "typescript": "^5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/crafts/carousel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/carousel.png
--------------------------------------------------------------------------------
/public/crafts/dynamic-input-label.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/dynamic-input-label.png
--------------------------------------------------------------------------------
/public/crafts/filters.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/filters.png
--------------------------------------------------------------------------------
/public/crafts/folder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/folder.png
--------------------------------------------------------------------------------
/public/crafts/order.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/order.png
--------------------------------------------------------------------------------
/public/crafts/quick-reaction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/quick-reaction.png
--------------------------------------------------------------------------------
/public/crafts/waitlist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/crafts/waitlist.png
--------------------------------------------------------------------------------
/public/fonts/Geist-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/fonts/Geist-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/Geist-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/fonts/Geist-Medium.ttf
--------------------------------------------------------------------------------
/public/fonts/Geist-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chroxify/website/c3a75c8f7dcf105d49959c03778557785547c00a/public/fonts/Geist-Regular.ttf
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./*"
28 | ],
29 | "content-collections": [
30 | "./.content-collections/generated"
31 | ]
32 | }
33 | },
34 | "include": [
35 | "next-env.d.ts",
36 | "**/*.ts",
37 | "**/*.tsx",
38 | ".next/types/**/*.ts"
39 | ],
40 | "exclude": [
41 | "node_modules"
42 | ]
43 | }
--------------------------------------------------------------------------------