├── app ├── favicon.ico ├── robots.ts ├── header.tsx ├── globals.css ├── blog │ ├── example-mdx-metadata │ │ └── page.mdx │ ├── layout.tsx │ └── exploring-the-intersection-of-design-ai-and-design-engineering │ │ └── page.mdx ├── layout.tsx ├── footer.tsx ├── data.ts └── page.tsx ├── lib ├── constants.ts └── utils.ts ├── public ├── cover.jpg ├── vercel.svg └── next.svg ├── postcss.config.mjs ├── .prettierrc.json ├── next.config.mjs ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── hooks └── useClickOutside.tsx ├── mdx-components.tsx ├── components └── ui │ ├── scroll-progress.tsx │ ├── text-loop.tsx │ ├── text-morph.tsx │ ├── animated-background.tsx │ ├── spotlight.tsx │ ├── magnetic.tsx │ ├── text-effect.tsx │ └── morphing-dialog.tsx ├── package.json ├── README.md └── INSTALLATION.md /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibelick/nim/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const WEBSITE_URL = 'https://nim-fawn.vercel.app' 2 | -------------------------------------------------------------------------------- /public/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibelick/nim/HEAD/public/cover.jpg -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "endOfLine": "auto", 8 | "arrowParens": "always", 9 | "plugins": ["prettier-plugin-tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import createMDX from '@next/mdx'; 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'], 7 | }; 8 | 9 | const withMDX = createMDX({ 10 | extension: /\.mdx?$/, 11 | }); 12 | 13 | export default withMDX(nextConfig); 14 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | import { WEBSITE_URL } from '@/lib/constants' 3 | 4 | export default function robots(): MetadataRoute.Robots { 5 | return { 6 | rules: { 7 | userAgent: '*', 8 | allow: '/', 9 | disallow: '/private/', 10 | }, 11 | sitemap: `${WEBSITE_URL}/sitemap.xml`, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends( 14 | "next/core-web-vitals", 15 | "next/typescript", 16 | "plugin:prettier/recommended", 17 | ), 18 | "plugin:mdx/recommended", 19 | ]; 20 | 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /.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 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /app/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { TextEffect } from '@/components/ui/text-effect' 3 | import Link from 'next/link' 4 | 5 | export function Header() { 6 | return ( 7 |
8 |
9 | 10 | Julien Nim 11 | 12 | 19 | Design Engineer 20 | 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | "next.config.mjs" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /hooks/useClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react' 2 | 3 | function useClickOutside( 4 | ref: RefObject, 5 | handler: (event: MouseEvent | TouchEvent) => void, 6 | ): void { 7 | useEffect(() => { 8 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 9 | if (!ref || !ref.current || ref.current.contains(event.target as Node)) { 10 | return 11 | } 12 | 13 | handler(event) 14 | } 15 | 16 | document.addEventListener('mousedown', handleClickOutside) 17 | document.addEventListener('touchstart', handleClickOutside) 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', handleClickOutside) 21 | document.removeEventListener('touchstart', handleClickOutside) 22 | } 23 | }, [ref, handler]) 24 | } 25 | 26 | export default useClickOutside 27 | -------------------------------------------------------------------------------- /mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from 'mdx/types' 2 | import { ComponentPropsWithoutRef } from 'react' 3 | import { highlight } from 'sugar-high' 4 | 5 | export function useMDXComponents(components: MDXComponents): MDXComponents { 6 | return { 7 | ...components, 8 | Cover: ({ 9 | src, 10 | alt, 11 | caption, 12 | }: { 13 | src: string 14 | alt: string 15 | caption: string 16 | }) => { 17 | return ( 18 |
19 | {alt} 20 |
{caption}
21 |
22 | ) 23 | }, 24 | code: ({ children, ...props }: ComponentPropsWithoutRef<'code'>) => { 25 | const codeHTML = highlight(children as string) 26 | return 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin '@tailwindcss/typography'; 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | /* 6 | The default border color has changed to `currentColor` in Tailwind CSS v4, 7 | so we've added these compatibility styles to make sure everything still 8 | looks the same as it did with Tailwind CSS v3. 9 | 10 | If we ever want to remove these styles, we need to add an explicit border 11 | color utility to any element that depends on these defaults. 12 | */ 13 | @layer base { 14 | *, 15 | ::after, 16 | ::before, 17 | ::backdrop, 18 | ::file-selector-button { 19 | border-color: var(--color-gray-200, currentColor); 20 | } 21 | } 22 | 23 | :root { 24 | --sh-class: #7aa2f7; 25 | --sh-sign: #89ddff; 26 | --sh-string: #9ece6a; 27 | --sh-keyword: #bb9af7; 28 | --sh-comment: #565f89; 29 | --sh-jsxliterals: #7aa2f7; 30 | --sh-property: #73daca; 31 | --sh-entity: #e0af68; 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/scroll-progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion, SpringOptions, useScroll, useSpring } from 'motion/react' 4 | import { cn } from '@/lib/utils' 5 | import { RefObject } from 'react' 6 | 7 | export type ScrollProgressProps = { 8 | className?: string 9 | springOptions?: SpringOptions 10 | containerRef?: RefObject 11 | } 12 | 13 | const DEFAULT_SPRING_OPTIONS: SpringOptions = { 14 | stiffness: 200, 15 | damping: 50, 16 | restDelta: 0.001, 17 | } 18 | 19 | export function ScrollProgress({ 20 | className, 21 | springOptions, 22 | containerRef, 23 | }: ScrollProgressProps) { 24 | const { scrollYProgress } = useScroll({ 25 | container: containerRef, 26 | layoutEffect: Boolean(containerRef?.current), 27 | }) 28 | 29 | const scaleX = useSpring(scrollYProgress, { 30 | ...DEFAULT_SPRING_OPTIONS, 31 | ...(springOptions ?? {}), 32 | }) 33 | 34 | return ( 35 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nim", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@mdx-js/loader": "^3.0.1", 13 | "@mdx-js/react": "^3.0.1", 14 | "@next/mdx": "^14.2.13", 15 | "@tailwindcss/typography": "^0.5.15", 16 | "@types/mdx": "^2.0.13", 17 | "clsx": "^2.1.1", 18 | "lucide-react": "^0.468.0", 19 | "motion": "^11.15.0", 20 | "next": "15.1.10", 21 | "next-themes": "^0.4.4", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "sugar-high": "^0.9.3", 25 | "tailwind-merge": "^2.5.5" 26 | }, 27 | "devDependencies": { 28 | "@eslint/eslintrc": "^3", 29 | "@tailwindcss/postcss": "^4.0.0", 30 | "@types/node": "^20", 31 | "@types/react": "^19", 32 | "@types/react-dom": "^19", 33 | "eslint": "^9", 34 | "eslint-config-next": "15.1.1", 35 | "postcss": "^8", 36 | "prettier": "^3.4.2", 37 | "prettier-plugin-tailwindcss": "^0.6.10", 38 | "tailwindcss": "^4.0.0", 39 | "typescript": "^5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/blog/example-mdx-metadata/page.mdx: -------------------------------------------------------------------------------- 1 | # How to Export Metadata from MDX for Next.js SEO 2 | 3 | Next.js supports exporting a `metadata` object from your MDX files, which can be used to enhance SEO and social sharing features. This is especially useful for blog posts and documentation pages. 4 | 5 | ## Example: Exporting Metadata 6 | 7 | You can export a `metadata` object at the top of your MDX file like this: 8 | 9 | ```javascript 10 | export const metadata = { 11 | title: 'How to Export Metadata from MDX for Next.js SEO', 12 | description: 'A guide on exporting metadata from MDX files to leverage Next.js SEO features.', 13 | authors: [{ name: 'Jane Doe', url: 'https://janedoe.com' }], 14 | openGraph: { 15 | images: [ 16 | 'https://cdn.cosmos.so/example-image.jpg', 17 | ], 18 | }, 19 | } 20 | ``` 21 | 22 | This metadata will be picked up by Next.js and can be used for SEO, Open Graph, and other meta tags. 23 | 24 | ## Why Use Metadata in MDX? 25 | 26 | - **SEO**: Improve your page's search engine ranking with relevant titles and descriptions. 27 | - **Social Sharing**: Enhance how your content appears when shared on social media platforms. 28 | - **Consistency**: Keep your metadata close to your content for easier management. 29 | 30 | ## Further Reading 31 | - [Next.js Documentation: Metadata](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) 32 | - [MDX Documentation](https://mdxjs.com/) 33 | 34 | --- 35 | 36 | _This post demonstrates how to export metadata from MDX files for use with Next.js SEO features, inspired by [leerob's site](https://github.com/leerob/site/blob/main/app/n/product-engineers/page.mdx)._ -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from 'next' 2 | import { Geist, Geist_Mono } from 'next/font/google' 3 | import './globals.css' 4 | import { Header } from './header' 5 | import { Footer } from './footer' 6 | import { ThemeProvider } from 'next-themes' 7 | 8 | export const viewport: Viewport = { 9 | width: 'device-width', 10 | initialScale: 1, 11 | themeColor: '#ffffff', 12 | } 13 | 14 | export const metadata: Metadata = { 15 | metadataBase: new URL('https://nim-fawn.vercel.app/'), 16 | alternates: { 17 | canonical: '/' 18 | }, 19 | title: { 20 | default: 'Nim - Personal website template', 21 | template: '%s | Nim' 22 | }, 23 | description: 'Nim is a free and open-source personal website template built with Next.js 15, React 19 and Motion-Primitives.', 24 | }; 25 | 26 | const geist = Geist({ 27 | variable: '--font-geist', 28 | subsets: ['latin'], 29 | }) 30 | 31 | const geistMono = Geist_Mono({ 32 | variable: '--font-geist-mono', 33 | subsets: ['latin'], 34 | }) 35 | 36 | export default function RootLayout({ 37 | children, 38 | }: Readonly<{ 39 | children: React.ReactNode 40 | }>) { 41 | return ( 42 | 43 | 46 | 52 |
53 |
54 |
55 | {children} 56 |
57 |
58 |
59 |
60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /app/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { TextMorph } from '@/components/ui/text-morph' 3 | import { ScrollProgress } from '@/components/ui/scroll-progress' 4 | import { useEffect, useState } from 'react' 5 | 6 | function CopyButton() { 7 | const [text, setText] = useState('Copy') 8 | const currentUrl = typeof window !== 'undefined' ? window.location.href : '' 9 | 10 | useEffect(() => { 11 | setTimeout(() => { 12 | setText('Copy') 13 | }, 2000) 14 | }, [text]) 15 | 16 | return ( 17 | 28 | ) 29 | } 30 | 31 | export default function LayoutBlogPost({ 32 | children, 33 | }: { 34 | children: React.ReactNode 35 | }) { 36 | return ( 37 | <> 38 |
39 | 45 | 46 |
47 | 48 |
49 |
50 | {children} 51 |
52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/ui/text-loop.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { cn } from '@/lib/utils' 3 | import { 4 | motion, 5 | AnimatePresence, 6 | Transition, 7 | Variants, 8 | AnimatePresenceProps, 9 | } from 'motion/react' 10 | import { useState, useEffect, Children } from 'react' 11 | 12 | export type TextLoopProps = { 13 | children: React.ReactNode[] 14 | className?: string 15 | interval?: number 16 | transition?: Transition 17 | variants?: Variants 18 | onIndexChange?: (index: number) => void 19 | trigger?: boolean 20 | mode?: AnimatePresenceProps['mode'] 21 | } 22 | 23 | export function TextLoop({ 24 | children, 25 | className, 26 | interval = 2, 27 | transition = { duration: 0.3 }, 28 | variants, 29 | onIndexChange, 30 | trigger = true, 31 | mode = 'popLayout', 32 | }: TextLoopProps) { 33 | const [currentIndex, setCurrentIndex] = useState(0) 34 | const items = Children.toArray(children) 35 | 36 | useEffect(() => { 37 | if (!trigger) return 38 | 39 | const intervalMs = interval * 1000 40 | const timer = setInterval(() => { 41 | setCurrentIndex((current) => { 42 | const next = (current + 1) % items.length 43 | onIndexChange?.(next) 44 | return next 45 | }) 46 | }, intervalMs) 47 | return () => clearInterval(timer) 48 | }, [items.length, interval, onIndexChange, trigger]) 49 | 50 | const motionVariants: Variants = { 51 | initial: { y: 20, opacity: 0 }, 52 | animate: { y: 0, opacity: 1 }, 53 | exit: { y: -20, opacity: 0 }, 54 | } 55 | 56 | return ( 57 |
58 | 59 | 67 | {items[currentIndex]} 68 | 69 | 70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cover image representing Nim, a personal website template 2 | 3 | Nim is a free and open-source personal website template built with Next.js 15, React 19, Tailwind CSS v4, and Motion. Designed for developers, designers, and founders, it combines minimalism with delightful animated components powered by [Motion-Primitives](https://motion-primitives.com). 4 | 5 | Live demo: [https://nim-fawn.vercel.app](https://nim-fawn.vercel.app) 6 | 7 | ## Features 8 | 9 | - Minimal one-page portfolio layout. 10 | - Blog support with MDX. 11 | - Responsive and accessible design. 12 | - Easy to use 13 | - [Motion-Primitives](https://motion-primitives.com) for animated components. 14 | 15 | ## Getting Started 16 | 17 | For detailed setup instructions, refer to the [Installation Guide](./INSTALLATION.md). 18 | 19 | ```bash 20 | git clone https://github.com/ibelick/nim.git 21 | cd nim 22 | npm install 23 | npm run dev 24 | ``` 25 | 26 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 27 | 28 | ## Contributing 29 | 30 | Contributions are welcome! Feel free to open issues or submit pull requests to improve Nim. 31 | 32 | ## Deployment 33 | 34 | You can deploy your site to any hosting platform that supports Next.js. For the easiest deployment experience, consider using Vercel: 35 | 36 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fibelick%2Fnim&env=NEXT_PUBLIC_SITE_URL&project-name=nim&repository-name=nim&redirect-url=https%3A%2F%2Ftwitter.com%2Fibelick&demo-title=Nim&demo-description=Nim%20is%20a%20free%20and%20open-source%20minimal%20personal%20website%20template%20built%20with%20Next.js%2015%2C%20React%2019%2C%20and%20Motion-Primitives.&demo-url=https%3A%2F%2Fnim.vercel.app&demo-image=https%3A%2F%2Fraw.githubusercontent.com%2Fibelick%2Fnim%2Frefs%2Fheads%2Fmain%2F.github%2Fassets%2Freadme.png&teamSlug=ibelick) 37 | 38 | ## About 39 | 40 | Nim is designed to make personal branding effortless and beautiful. If you enjoy it, consider sharing it and exploring [Motion-Primitives Pro](https://pro.motion-primitives.com/). 41 | -------------------------------------------------------------------------------- /components/ui/text-morph.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { cn } from '@/lib/utils' 3 | import { AnimatePresence, motion, Transition, Variants } from 'motion/react' 4 | import { useMemo, useId } from 'react' 5 | 6 | export type TextMorphProps = { 7 | children: string 8 | as?: React.ElementType 9 | className?: string 10 | style?: React.CSSProperties 11 | variants?: Variants 12 | transition?: Transition 13 | } 14 | 15 | export function TextMorph({ 16 | children, 17 | as: Component = 'p', 18 | className, 19 | style, 20 | variants, 21 | transition, 22 | }: TextMorphProps) { 23 | const uniqueId = useId() 24 | 25 | const characters = useMemo(() => { 26 | const charCounts: Record = {} 27 | 28 | return children.split('').map((char, index) => { 29 | const lowerChar = char.toLowerCase() 30 | charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1 31 | 32 | return { 33 | id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`, 34 | label: 35 | char === ' ' 36 | ? '\u00A0' 37 | : index === 0 38 | ? char.toUpperCase() 39 | : lowerChar, 40 | } 41 | }) 42 | }, [children, uniqueId]) 43 | 44 | const defaultVariants: Variants = { 45 | initial: { opacity: 0 }, 46 | animate: { opacity: 1 }, 47 | exit: { opacity: 0 }, 48 | } 49 | 50 | const defaultTransition: Transition = { 51 | type: 'spring', 52 | stiffness: 280, 53 | damping: 18, 54 | mass: 0.3, 55 | } 56 | 57 | return ( 58 | 59 | 60 | {characters.map((character) => ( 61 | 74 | ))} 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Nim - Installation Guide 2 | 3 | ## Prerequisites 4 | 5 | - Node.js 20.x or later 6 | - Git 7 | 8 | ## Installation Steps 9 | 10 | 1. **Clone the repository** 11 | 12 | ```bash 13 | git clone https://github.com/ibelick/nim.git 14 | cd nim 15 | ``` 16 | 17 | 2. **Install dependencies** 18 | 19 | ```bash 20 | npm install 21 | ``` 22 | 23 | 3. **Run the development server** 24 | 25 | ```bash 26 | npm run dev 27 | ``` 28 | 29 | 4. **Update the template data** 30 | 31 | Update the template data in the `app/data.ts` file. 32 | 33 | ```ts 34 | export const EMAIL = 'your@email.com' 35 | 36 | export const SOCIAL_LINKS = [ 37 | { 38 | label: 'Github', 39 | link: 'your-github-url', 40 | }, 41 | // Add your social links 42 | ] 43 | 44 | ... 45 | ``` 46 | 47 | 5. **Add your blog posts** 48 | 49 | Create a new .mdx file for each blog post inside the app/blog folder. For example: 50 | app/blog/your-article-slug/page.mdx. 51 | 52 | Example blog post structure in .mdx: 53 | 54 | ```mdx 55 | # Your Article Title 56 | 57 | Introduction 58 | 59 | Your content here... 60 | 61 | ## Code Examples 62 | 63 | // Example code block here... 64 | ``` 65 | 66 | **Note:** You can use all MDX features, including React components, in your blog posts. 67 | 68 | 6. **Project Structure** 69 | 70 | For a better understanding of the Next.js project structure, refer to the [Next.js](https://nextjs.org/docs/app/getting-started/project-structure) documentation. 71 | 72 | 7. **Additional Features** 73 | 74 | Want to add more animated components? 75 | Check out [Motion-Primitives](https://motion-primitives.com/) for additional animation components and templates. If you want something else DM on [X](https://x.com/Ibelick). 76 | 77 | 8. **Deployment** 78 | 79 | You can deploy your site to any hosting platform that supports Next.js. For the easiest deployment experience, consider using Vercel: 80 | 81 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fibelick%2Fnim&env=NEXT_PUBLIC_SITE_URL&project-name=nim&repository-name=nim&redirect-url=https%3A%2F%2Ftwitter.com%2Fibelick&demo-title=Nim&demo-description=Nim%20is%20a%20free%20and%20open-source%20minimal%20personal%20website%20template%20built%20with%20Next.js%2015%2C%20React%2019%2C%20and%20Motion-Primitives.&demo-url=https%3A%2F%2Fnim.vercel.app&demo-image=https%3A%2F%2Fraw.githubusercontent.com%2Fibelick%2Fnim%2Frefs%2Fheads%2Fmain%2F.github%2Fassets%2Freadme.png&teamSlug=ibelick) 82 | -------------------------------------------------------------------------------- /components/ui/animated-background.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { cn } from '@/lib/utils' 3 | import { AnimatePresence, Transition, motion } from 'motion/react' 4 | import { 5 | Children, 6 | cloneElement, 7 | ReactElement, 8 | useEffect, 9 | useState, 10 | useId, 11 | } from 'react' 12 | 13 | export type AnimatedBackgroundProps = { 14 | children: 15 | | ReactElement<{ 'data-id': string }>[] 16 | | ReactElement<{ 'data-id': string }> 17 | defaultValue?: string 18 | onValueChange?: (newActiveId: string | null) => void 19 | className?: string 20 | transition?: Transition 21 | enableHover?: boolean 22 | } 23 | 24 | export function AnimatedBackground({ 25 | children, 26 | defaultValue, 27 | onValueChange, 28 | className, 29 | transition, 30 | enableHover = false, 31 | }: AnimatedBackgroundProps) { 32 | const [activeId, setActiveId] = useState(null) 33 | const uniqueId = useId() 34 | 35 | const handleSetActiveId = (id: string | null) => { 36 | setActiveId(id) 37 | 38 | if (onValueChange) { 39 | onValueChange(id) 40 | } 41 | } 42 | 43 | useEffect(() => { 44 | if (defaultValue !== undefined) { 45 | setActiveId(defaultValue) 46 | } 47 | }, [defaultValue]) 48 | 49 | return Children.map(children, (child: any, index) => { 50 | const id = child.props['data-id'] 51 | 52 | const interactionProps = enableHover 53 | ? { 54 | onMouseEnter: () => handleSetActiveId(id), 55 | onMouseLeave: () => handleSetActiveId(null), 56 | } 57 | : { 58 | onClick: () => handleSetActiveId(id), 59 | } 60 | 61 | return cloneElement( 62 | child, 63 | { 64 | key: index, 65 | className: cn('relative inline-flex', child.props.className), 66 | 'data-checked': activeId === id ? 'true' : 'false', 67 | ...interactionProps, 68 | }, 69 | <> 70 | 71 | {activeId === id && ( 72 | 84 | )} 85 | 86 |
{child.props.children}
87 | , 88 | ) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /app/footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { AnimatedBackground } from '@/components/ui/animated-background' 3 | import { TextLoop } from '@/components/ui/text-loop' 4 | import { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react' 5 | import { useTheme } from 'next-themes' 6 | import { useEffect, useState } from 'react' 7 | 8 | const THEMES_OPTIONS = [ 9 | { 10 | label: 'Light', 11 | id: 'light', 12 | icon: , 13 | }, 14 | { 15 | label: 'Dark', 16 | id: 'dark', 17 | icon: , 18 | }, 19 | { 20 | label: 'System', 21 | id: 'system', 22 | icon: , 23 | }, 24 | ] 25 | 26 | function ThemeSwitch() { 27 | const [mounted, setMounted] = useState(false) 28 | const { theme, setTheme } = useTheme() 29 | 30 | useEffect(() => { 31 | setMounted(true) 32 | }, []) 33 | 34 | if (!mounted) { 35 | return null 36 | } 37 | 38 | return ( 39 | { 49 | setTheme(id as string) 50 | }} 51 | > 52 | {THEMES_OPTIONS.map((theme) => { 53 | return ( 54 | 63 | ) 64 | })} 65 | 66 | ) 67 | } 68 | 69 | export function Footer() { 70 | return ( 71 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /components/ui/spotlight.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React, { useRef, useState, useCallback, useEffect } from 'react' 3 | import { motion, useSpring, useTransform, SpringOptions } from 'motion/react' 4 | import { cn } from '@/lib/utils' 5 | 6 | export type SpotlightProps = { 7 | className?: string 8 | size?: number 9 | springOptions?: SpringOptions 10 | } 11 | 12 | export function Spotlight({ 13 | className, 14 | size = 200, 15 | springOptions = { bounce: 0 }, 16 | }: SpotlightProps) { 17 | const containerRef = useRef(null) 18 | const [isHovered, setIsHovered] = useState(false) 19 | const [parentElement, setParentElement] = useState(null) 20 | 21 | const mouseX = useSpring(0, springOptions) 22 | const mouseY = useSpring(0, springOptions) 23 | 24 | const spotlightLeft = useTransform(mouseX, (x) => `${x - size / 2}px`) 25 | const spotlightTop = useTransform(mouseY, (y) => `${y - size / 2}px`) 26 | 27 | useEffect(() => { 28 | if (containerRef.current) { 29 | const parent = containerRef.current.parentElement 30 | if (parent) { 31 | parent.style.position = 'relative' 32 | parent.style.overflow = 'hidden' 33 | setParentElement(parent) 34 | } 35 | } 36 | }, []) 37 | 38 | const handleMouseMove = useCallback( 39 | (event: MouseEvent) => { 40 | if (!parentElement) return 41 | const { left, top } = parentElement.getBoundingClientRect() 42 | mouseX.set(event.clientX - left) 43 | mouseY.set(event.clientY - top) 44 | }, 45 | [mouseX, mouseY, parentElement], 46 | ) 47 | 48 | useEffect(() => { 49 | if (!parentElement) return 50 | 51 | parentElement.addEventListener('mousemove', handleMouseMove) 52 | parentElement.addEventListener('mouseenter', () => setIsHovered(true)) 53 | parentElement.addEventListener('mouseleave', () => setIsHovered(false)) 54 | 55 | return () => { 56 | parentElement.removeEventListener('mousemove', handleMouseMove) 57 | parentElement.removeEventListener('mouseenter', () => setIsHovered(true)) 58 | parentElement.removeEventListener('mouseleave', () => setIsHovered(false)) 59 | } 60 | }, [parentElement, handleMouseMove]) 61 | 62 | return ( 63 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /components/ui/magnetic.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState, useEffect, useRef } from 'react' 4 | import { 5 | motion, 6 | useMotionValue, 7 | useSpring, 8 | type SpringOptions, 9 | } from 'motion/react' 10 | 11 | const SPRING_CONFIG = { stiffness: 26.7, damping: 4.1, mass: 0.2 } 12 | 13 | export type MagneticProps = { 14 | children: React.ReactNode 15 | intensity?: number 16 | range?: number 17 | actionArea?: 'self' | 'parent' | 'global' 18 | springOptions?: SpringOptions 19 | } 20 | 21 | export function Magnetic({ 22 | children, 23 | intensity = 0.6, 24 | range = 100, 25 | actionArea = 'self', 26 | springOptions = SPRING_CONFIG, 27 | }: MagneticProps) { 28 | const [isHovered, setIsHovered] = useState(false) 29 | const ref = useRef(null) 30 | 31 | const x = useMotionValue(0) 32 | const y = useMotionValue(0) 33 | 34 | const springX = useSpring(x, springOptions) 35 | const springY = useSpring(y, springOptions) 36 | 37 | useEffect(() => { 38 | const calculateDistance = (e: MouseEvent) => { 39 | if (ref.current) { 40 | const rect = ref.current.getBoundingClientRect() 41 | const centerX = rect.left + rect.width / 2 42 | const centerY = rect.top + rect.height / 2 43 | const distanceX = e.clientX - centerX 44 | const distanceY = e.clientY - centerY 45 | 46 | const absoluteDistance = Math.sqrt(distanceX ** 2 + distanceY ** 2) 47 | 48 | if (isHovered && absoluteDistance <= range) { 49 | const scale = 1 - absoluteDistance / range 50 | x.set(distanceX * intensity * scale) 51 | y.set(distanceY * intensity * scale) 52 | } else { 53 | x.set(0) 54 | y.set(0) 55 | } 56 | } 57 | } 58 | 59 | document.addEventListener('mousemove', calculateDistance) 60 | 61 | return () => { 62 | document.removeEventListener('mousemove', calculateDistance) 63 | } 64 | }, [ref, isHovered, intensity, range]) 65 | 66 | useEffect(() => { 67 | if (actionArea === 'parent' && ref.current?.parentElement) { 68 | const parent = ref.current.parentElement 69 | 70 | const handleParentEnter = () => setIsHovered(true) 71 | const handleParentLeave = () => setIsHovered(false) 72 | 73 | parent.addEventListener('mouseenter', handleParentEnter) 74 | parent.addEventListener('mouseleave', handleParentLeave) 75 | 76 | return () => { 77 | parent.removeEventListener('mouseenter', handleParentEnter) 78 | parent.removeEventListener('mouseleave', handleParentLeave) 79 | } 80 | } else if (actionArea === 'global') { 81 | setIsHovered(true) 82 | } 83 | }, [actionArea]) 84 | 85 | const handleMouseEnter = () => { 86 | if (actionArea === 'self') { 87 | setIsHovered(true) 88 | } 89 | } 90 | 91 | const handleMouseLeave = () => { 92 | if (actionArea === 'self') { 93 | setIsHovered(false) 94 | x.set(0) 95 | y.set(0) 96 | } 97 | } 98 | 99 | return ( 100 | 109 | {children} 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /app/data.ts: -------------------------------------------------------------------------------- 1 | type Project = { 2 | name: string 3 | description: string 4 | link: string 5 | video: string 6 | id: string 7 | } 8 | 9 | type WorkExperience = { 10 | company: string 11 | title: string 12 | start: string 13 | end: string 14 | link: string 15 | id: string 16 | } 17 | 18 | type BlogPost = { 19 | title: string 20 | description: string 21 | link: string 22 | uid: string 23 | } 24 | 25 | type SocialLink = { 26 | label: string 27 | link: string 28 | } 29 | 30 | export const PROJECTS: Project[] = [ 31 | { 32 | name: 'Motion Primitives Pro', 33 | description: 34 | 'Advanced components and templates to craft beautiful websites.', 35 | link: 'https://pro.motion-primitives.com/', 36 | video: 37 | 'https://res.cloudinary.com/read-cv/video/upload/t_v_b/v1/1/profileItems/W2azTw5BVbMXfj7F53G92hMVIn32/newProfileItem/d898be8a-7037-4c71-af0c-8997239b050d.mp4?_a=DATAdtAAZAA0', 38 | id: 'project1', 39 | }, 40 | { 41 | name: 'Motion Primitives', 42 | description: 'UI kit to make beautiful, animated interfaces.', 43 | link: 'https://motion-primitives.com/', 44 | video: 45 | 'https://res.cloudinary.com/read-cv/video/upload/t_v_b/v1/1/profileItems/W2azTw5BVbMXfj7F53G92hMVIn32/XSfIvT7BUWbPRXhrbLed/ee6871c9-8400-49d2-8be9-e32675eabf7e.mp4?_a=DATAdtAAZAA0', 46 | id: 'project2', 47 | }, 48 | ] 49 | 50 | export const WORK_EXPERIENCE: WorkExperience[] = [ 51 | { 52 | company: 'Reglazed Studio', 53 | title: 'CEO', 54 | start: '2024', 55 | end: 'Present', 56 | link: 'https://ibelick.com', 57 | id: 'work1', 58 | }, 59 | { 60 | company: 'Freelance', 61 | title: 'Design Engineer', 62 | start: '2022', 63 | end: '2024', 64 | link: 'https://ibelick.com', 65 | id: 'work2', 66 | }, 67 | { 68 | company: 'Freelance', 69 | title: 'Front-end Developer', 70 | start: '2017', 71 | end: 'Present', 72 | link: 'https://ibelick.com', 73 | id: 'work3', 74 | }, 75 | ] 76 | 77 | export const BLOG_POSTS: BlogPost[] = [ 78 | { 79 | title: 'Exploring the Intersection of Design, AI, and Design Engineering', 80 | description: 'How AI is changing the way we design', 81 | link: '/blog/exploring-the-intersection-of-design-ai-and-design-engineering', 82 | uid: 'blog-1', 83 | }, 84 | { 85 | title: 'Why I left my job to start my own company', 86 | description: 87 | 'A deep dive into my decision to leave my job and start my own company', 88 | link: '/blog/exploring-the-intersection-of-design-ai-and-design-engineering', 89 | uid: 'blog-2', 90 | }, 91 | { 92 | title: 'What I learned from my first year of freelancing', 93 | description: 94 | 'A look back at my first year of freelancing and what I learned', 95 | link: '/blog/exploring-the-intersection-of-design-ai-and-design-engineering', 96 | uid: 'blog-3', 97 | }, 98 | { 99 | title: 'How to Export Metadata from MDX for Next.js SEO', 100 | description: 'A guide on exporting metadata from MDX files to leverage Next.js SEO features.', 101 | link: '/blog/example-mdx-metadata', 102 | uid: 'blog-4', 103 | }, 104 | ] 105 | 106 | export const SOCIAL_LINKS: SocialLink[] = [ 107 | { 108 | label: 'Github', 109 | link: 'https://github.com/ibelick', 110 | }, 111 | { 112 | label: 'Twitter', 113 | link: 'https://twitter.com/ibelick', 114 | }, 115 | { 116 | label: 'LinkedIn', 117 | link: 'https://www.linkedin.com/in/ibelick', 118 | }, 119 | { 120 | label: 'Instagram', 121 | link: 'https://www.instagram.com/ibelick', 122 | }, 123 | ] 124 | 125 | export const EMAIL = 'your@email.com' 126 | -------------------------------------------------------------------------------- /app/blog/exploring-the-intersection-of-design-ai-and-design-engineering/page.mdx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Exploring the Intersection of Design, AI, and Design Engineering', 3 | description: 4 | 'Design and artificial intelligence (AI) are increasingly intertwined, driving innovation across industries. As technology evolves, the role of design engineering is more critical than ever, bridging creativity and functionality.', 5 | alternates: { 6 | canonical: '/blog/exploring-the-intersection-of-design-ai-and-design-engineering', 7 | }, 8 | }; 9 | 10 | 15 | 16 | # Exploring the Intersection of Design, AI, and Design Engineering 17 | 18 | Design and artificial intelligence (AI) are increasingly intertwined, driving innovation across industries. As technology evolves, the role of design engineering is more critical than ever, bridging creativity and functionality. 19 | 20 | --- 21 | 22 | ## The Evolving Role of AI in Design 23 | 24 | AI is no longer just a backend tool—it’s becoming an active collaborator in the creative process. From generating design ideas to optimizing layouts, AI offers endless possibilities. For instance: 25 | 26 | - **Generative Design**: AI algorithms can generate thousands of design variations based on constraints, helping designers explore ideas faster. 27 | - **User Experience Optimization**: AI analyzes user behavior to suggest improvements, enabling data-driven design decisions. 28 | - **Automation**: Repetitive tasks like resizing assets or formatting layouts can be automated, freeing up designers for more strategic work. 29 | 30 | ## Challenges and Opportunities 31 | 32 | While AI empowers designers, it also raises challenges: 33 | 34 | ### Challenges 35 | 36 | - **Ethical Design**: AI systems may unintentionally reinforce biases. Designers must ensure inclusivity and fairness. 37 | - **Loss of Control**: Relying too heavily on AI can dilute the human touch in design. 38 | - **Learning Curve**: Integrating AI tools requires new skills, which can be daunting for some designers. 39 | 40 | ### Opportunities 41 | 42 | - **Enhanced Creativity**: AI can inspire by offering unconventional ideas. 43 | - **Efficiency Gains**: Automation allows for rapid iteration and prototyping. 44 | - **Scalability**: AI enables personalized experiences at scale, a growing demand in today’s market. 45 | 46 | --- 47 | 48 | ## Design Engineering: The Glue Between Creativity and Execution 49 | 50 | Design engineering ensures that the gap between creative vision and technical execution is seamless. It combines the artistry of design with the precision of engineering. 51 | 52 | ### Key Principles of Design Engineering 53 | 54 | 1. **Systems Thinking**: Viewing a design holistically ensures all components work together cohesively. 55 | 2. **Collaboration**: Effective communication between designers and developers is crucial for successful outcomes. 56 | 3. **Iterative Process**: Building, testing, and refining are fundamental to achieving the best results. 57 | 58 | > "Good design is as little design as possible." — Dieter Rams 59 | 60 | ### Tools of the Trade 61 | 62 | Modern design engineers leverage tools like: 63 | 64 | - **Figma** and **Sketch** for prototyping 65 | - **Motion** for creating smooth animations 66 | - **Tailwind CSS** for streamlined styling 67 | - **Git** for version control and collaboration 68 | 69 | --- 70 | 71 | ## AI and Design Engineering: A Symbiotic Relationship 72 | 73 | The integration of AI into design engineering creates powerful synergies: 74 | 75 | - **Prototyping with AI**: AI-driven tools like ChatGPT assist in generating content for prototypes, accelerating the design process. 76 | - **Predictive Analytics**: Engineers use AI to predict performance issues and optimize designs in real-time. 77 | - **Accessibility Improvements**: AI tools automatically detect and fix accessibility concerns, ensuring compliance. 78 | 79 | ### Case Study: Motion-Primitives 80 | 81 | Motion-Primitives demonstrates how AI and design engineering come together. By leveraging Framer Motion and Tailwind CSS, it simplifies the creation of dynamic, responsive interfaces. AI can enhance this by: 82 | 83 | - Generating motion patterns based on user preferences. 84 | - Optimizing performance for different devices. 85 | - Automating testing for cross-browser compatibility. 86 | 87 | --- 88 | 89 | ## Conclusion 90 | 91 | The intersection of AI, design, and design engineering is reshaping the industry. By embracing AI while staying grounded in design principles, professionals can push boundaries and create experiences that are both innovative and human-centered. The future lies in collaboration—not only between humans and machines but also among designers, engineers, and AI. 92 | 93 | --- 94 | 95 | ### Questions for Reflection 96 | 97 | - How can we ensure AI remains a tool for empowerment rather than replacement? 98 | - What steps can design engineers take to integrate AI responsibly into their workflows? 99 | 100 | ### Further Reading 101 | 102 | - [Designing for AI](https://example.com/designing-for-ai) 103 | - [The Future of Design Systems](https://example.com/future-design-systems) 104 | - [Ethical AI Guidelines](https://example.com/ethical-ai) 105 | 106 | --- 107 | 108 | ### Music for Inspiration 109 | 110 | Listening to music while working? Check out _"Motion"_ by Tycho—a perfect blend of creativity and rhythm. 111 | -------------------------------------------------------------------------------- /components/ui/text-effect.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { cn } from '@/lib/utils' 3 | import { 4 | AnimatePresence, 5 | motion, 6 | TargetAndTransition, 7 | Transition, 8 | Variant, 9 | Variants, 10 | } from 'motion/react' 11 | import React from 'react' 12 | 13 | export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide' 14 | 15 | export type PerType = 'word' | 'char' | 'line' 16 | 17 | export type TextEffectProps = { 18 | children: string 19 | per?: PerType 20 | as?: keyof React.JSX.IntrinsicElements 21 | variants?: { 22 | container?: Variants 23 | item?: Variants 24 | } 25 | className?: string 26 | preset?: PresetType 27 | delay?: number 28 | speedReveal?: number 29 | speedSegment?: number 30 | trigger?: boolean 31 | onAnimationComplete?: () => void 32 | onAnimationStart?: () => void 33 | segmentWrapperClassName?: string 34 | containerTransition?: Transition 35 | segmentTransition?: Transition 36 | style?: React.CSSProperties 37 | } 38 | 39 | const defaultStaggerTimes: Record = { 40 | char: 0.03, 41 | word: 0.05, 42 | line: 0.1, 43 | } 44 | 45 | const defaultContainerVariants: Variants = { 46 | hidden: { opacity: 0 }, 47 | visible: { 48 | opacity: 1, 49 | transition: { 50 | staggerChildren: 0.05, 51 | }, 52 | }, 53 | exit: { 54 | transition: { staggerChildren: 0.05, staggerDirection: -1 }, 55 | }, 56 | } 57 | 58 | const defaultItemVariants: Variants = { 59 | hidden: { opacity: 0 }, 60 | visible: { 61 | opacity: 1, 62 | }, 63 | exit: { opacity: 0 }, 64 | } 65 | 66 | const presetVariants: Record< 67 | PresetType, 68 | { container: Variants; item: Variants } 69 | > = { 70 | blur: { 71 | container: defaultContainerVariants, 72 | item: { 73 | hidden: { opacity: 0, filter: 'blur(12px)' }, 74 | visible: { opacity: 1, filter: 'blur(0px)' }, 75 | exit: { opacity: 0, filter: 'blur(12px)' }, 76 | }, 77 | }, 78 | 'fade-in-blur': { 79 | container: defaultContainerVariants, 80 | item: { 81 | hidden: { opacity: 0, y: 20, filter: 'blur(12px)' }, 82 | visible: { opacity: 1, y: 0, filter: 'blur(0px)' }, 83 | exit: { opacity: 0, y: 20, filter: 'blur(12px)' }, 84 | }, 85 | }, 86 | scale: { 87 | container: defaultContainerVariants, 88 | item: { 89 | hidden: { opacity: 0, scale: 0 }, 90 | visible: { opacity: 1, scale: 1 }, 91 | exit: { opacity: 0, scale: 0 }, 92 | }, 93 | }, 94 | fade: { 95 | container: defaultContainerVariants, 96 | item: { 97 | hidden: { opacity: 0 }, 98 | visible: { opacity: 1 }, 99 | exit: { opacity: 0 }, 100 | }, 101 | }, 102 | slide: { 103 | container: defaultContainerVariants, 104 | item: { 105 | hidden: { opacity: 0, y: 20 }, 106 | visible: { opacity: 1, y: 0 }, 107 | exit: { opacity: 0, y: 20 }, 108 | }, 109 | }, 110 | } 111 | 112 | const AnimationComponent: React.FC<{ 113 | segment: string 114 | variants: Variants 115 | per: 'line' | 'word' | 'char' 116 | segmentWrapperClassName?: string 117 | }> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => { 118 | const content = 119 | per === 'line' ? ( 120 | 121 | {segment} 122 | 123 | ) : per === 'word' ? ( 124 | 131 | ) : ( 132 | 133 | {segment.split('').map((char, charIndex) => ( 134 | 142 | ))} 143 | 144 | ) 145 | 146 | if (!segmentWrapperClassName) { 147 | return content 148 | } 149 | 150 | const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block' 151 | 152 | return ( 153 | 154 | {content} 155 | 156 | ) 157 | }) 158 | 159 | AnimationComponent.displayName = 'AnimationComponent' 160 | 161 | const splitText = (text: string, per: 'line' | 'word' | 'char') => { 162 | if (per === 'line') return text.split('\n') 163 | return text.split(/(\s+)/) 164 | } 165 | 166 | const hasTransition = ( 167 | variant: Variant, 168 | ): variant is TargetAndTransition & { transition?: Transition } => { 169 | return ( 170 | typeof variant === 'object' && variant !== null && 'transition' in variant 171 | ) 172 | } 173 | 174 | const createVariantsWithTransition = ( 175 | baseVariants: Variants, 176 | transition?: Transition & { exit?: Transition }, 177 | ): Variants => { 178 | if (!transition) return baseVariants 179 | 180 | const { exit: _, ...mainTransition } = transition 181 | 182 | return { 183 | ...baseVariants, 184 | visible: { 185 | ...baseVariants.visible, 186 | transition: { 187 | ...(hasTransition(baseVariants.visible) 188 | ? baseVariants.visible.transition 189 | : {}), 190 | ...mainTransition, 191 | }, 192 | }, 193 | exit: { 194 | ...baseVariants.exit, 195 | transition: { 196 | ...(hasTransition(baseVariants.exit) 197 | ? baseVariants.exit.transition 198 | : {}), 199 | ...mainTransition, 200 | staggerDirection: -1, 201 | }, 202 | }, 203 | } 204 | } 205 | 206 | export function TextEffect({ 207 | children, 208 | per = 'word', 209 | as = 'p', 210 | variants, 211 | className, 212 | preset = 'fade', 213 | delay = 0, 214 | speedReveal = 1, 215 | speedSegment = 1, 216 | trigger = true, 217 | onAnimationComplete, 218 | onAnimationStart, 219 | segmentWrapperClassName, 220 | containerTransition, 221 | segmentTransition, 222 | style, 223 | }: TextEffectProps) { 224 | const segments = splitText(children, per) 225 | const MotionTag = motion[as as keyof typeof motion] as typeof motion.div 226 | 227 | const baseVariants = preset 228 | ? presetVariants[preset] 229 | : { container: defaultContainerVariants, item: defaultItemVariants } 230 | 231 | const stagger = defaultStaggerTimes[per] / speedReveal 232 | 233 | const baseDuration = 0.3 / speedSegment 234 | 235 | const customStagger = hasTransition(variants?.container?.visible ?? {}) 236 | ? (variants?.container?.visible as TargetAndTransition).transition 237 | ?.staggerChildren 238 | : undefined 239 | 240 | const customDelay = hasTransition(variants?.container?.visible ?? {}) 241 | ? (variants?.container?.visible as TargetAndTransition).transition 242 | ?.delayChildren 243 | : undefined 244 | 245 | const computedVariants = { 246 | container: createVariantsWithTransition( 247 | variants?.container || baseVariants.container, 248 | { 249 | staggerChildren: customStagger ?? stagger, 250 | delayChildren: customDelay ?? delay, 251 | ...containerTransition, 252 | exit: { 253 | staggerChildren: customStagger ?? stagger, 254 | staggerDirection: -1, 255 | }, 256 | }, 257 | ), 258 | item: createVariantsWithTransition(variants?.item || baseVariants.item, { 259 | duration: baseDuration, 260 | ...segmentTransition, 261 | }), 262 | } 263 | 264 | return ( 265 | 266 | {trigger && ( 267 | 277 | {per !== 'line' ? {children} : null} 278 | {segments.map((segment, index) => ( 279 | 286 | ))} 287 | 288 | )} 289 | 290 | ) 291 | } 292 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { motion } from 'motion/react' 3 | import { XIcon } from 'lucide-react' 4 | import { Spotlight } from '@/components/ui/spotlight' 5 | import { Magnetic } from '@/components/ui/magnetic' 6 | import { 7 | MorphingDialog, 8 | MorphingDialogTrigger, 9 | MorphingDialogContent, 10 | MorphingDialogClose, 11 | MorphingDialogContainer, 12 | } from '@/components/ui/morphing-dialog' 13 | import Link from 'next/link' 14 | import { AnimatedBackground } from '@/components/ui/animated-background' 15 | import { 16 | PROJECTS, 17 | WORK_EXPERIENCE, 18 | BLOG_POSTS, 19 | EMAIL, 20 | SOCIAL_LINKS, 21 | } from './data' 22 | 23 | const VARIANTS_CONTAINER = { 24 | hidden: { opacity: 0 }, 25 | visible: { 26 | opacity: 1, 27 | transition: { 28 | staggerChildren: 0.15, 29 | }, 30 | }, 31 | } 32 | 33 | const VARIANTS_SECTION = { 34 | hidden: { opacity: 0, y: 20, filter: 'blur(8px)' }, 35 | visible: { opacity: 1, y: 0, filter: 'blur(0px)' }, 36 | } 37 | 38 | const TRANSITION_SECTION = { 39 | duration: 0.3, 40 | } 41 | 42 | type ProjectVideoProps = { 43 | src: string 44 | } 45 | 46 | function ProjectVideo({ src }: ProjectVideoProps) { 47 | return ( 48 | 55 | 56 | 64 | 65 | 66 | 74 | 85 | 86 | 87 | 88 | 89 | ) 90 | } 91 | 92 | function MagneticSocialLink({ 93 | children, 94 | link, 95 | }: { 96 | children: React.ReactNode 97 | link: string 98 | }) { 99 | return ( 100 | 101 | 105 | {children} 106 | 114 | 120 | 121 | 122 | 123 | ) 124 | } 125 | 126 | export default function Personal() { 127 | return ( 128 | 134 | 138 |
139 |

140 | Focused on creating intuitive and performant web experiences. 141 | Bridging the gap between design and development. 142 |

143 |
144 |
145 | 146 | 150 |

Selected Projects

151 |
152 | {PROJECTS.map((project) => ( 153 |
154 |
155 | 156 |
157 |
158 | 163 | {project.name} 164 | 165 | 166 |

167 | {project.description} 168 |

169 |
170 |
171 | ))} 172 |
173 |
174 | 175 | 179 |

Work Experience

180 | 211 |
212 | 213 | 217 |

Blog

218 |
219 | 228 | {BLOG_POSTS.map((post) => ( 229 | 235 |
236 |

237 | {post.title} 238 |

239 |

240 | {post.description} 241 |

242 |
243 | 244 | ))} 245 |
246 |
247 |
248 | 249 | 253 |

Connect

254 |

255 | Feel free to contact me at{' '} 256 | 257 | {EMAIL} 258 | 259 |

260 |
261 | {SOCIAL_LINKS.map((link) => ( 262 | 263 | {link.label} 264 | 265 | ))} 266 |
267 |
268 |
269 | ) 270 | } 271 | -------------------------------------------------------------------------------- /components/ui/morphing-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useId, 8 | useMemo, 9 | useRef, 10 | useState, 11 | } from 'react' 12 | import { 13 | motion, 14 | AnimatePresence, 15 | MotionConfig, 16 | Transition, 17 | Variant, 18 | } from 'motion/react' 19 | import { createPortal } from 'react-dom' 20 | import { cn } from '@/lib/utils' 21 | import { XIcon } from 'lucide-react' 22 | import useClickOutside from '@/hooks/useClickOutside' 23 | 24 | export type MorphingDialogContextType = { 25 | isOpen: boolean 26 | setIsOpen: React.Dispatch> 27 | uniqueId: string 28 | triggerRef: React.RefObject 29 | } 30 | 31 | const MorphingDialogContext = 32 | React.createContext(null) 33 | 34 | function useMorphingDialog() { 35 | const context = useContext(MorphingDialogContext) 36 | if (!context) { 37 | throw new Error( 38 | 'useMorphingDialog must be used within a MorphingDialogProvider', 39 | ) 40 | } 41 | return context 42 | } 43 | 44 | export type MorphingDialogProviderProps = { 45 | children: React.ReactNode 46 | transition?: Transition 47 | } 48 | 49 | function MorphingDialogProvider({ 50 | children, 51 | transition, 52 | }: MorphingDialogProviderProps) { 53 | const [isOpen, setIsOpen] = useState(false) 54 | const uniqueId = useId() 55 | const triggerRef = useRef(null!) 56 | 57 | const contextValue = useMemo( 58 | () => ({ 59 | isOpen, 60 | setIsOpen, 61 | uniqueId, 62 | triggerRef, 63 | }), 64 | [isOpen, uniqueId], 65 | ) 66 | 67 | return ( 68 | 69 | {children} 70 | 71 | ) 72 | } 73 | 74 | export type MorphingDialogProps = { 75 | children: React.ReactNode 76 | transition?: Transition 77 | } 78 | 79 | function MorphingDialog({ children, transition }: MorphingDialogProps) { 80 | return ( 81 | 82 | {children} 83 | 84 | ) 85 | } 86 | 87 | export type MorphingDialogTriggerProps = { 88 | children: React.ReactNode 89 | className?: string 90 | style?: React.CSSProperties 91 | triggerRef?: React.RefObject 92 | } 93 | 94 | function MorphingDialogTrigger({ 95 | children, 96 | className, 97 | style, 98 | triggerRef, 99 | }: MorphingDialogTriggerProps) { 100 | const { setIsOpen, isOpen, uniqueId } = useMorphingDialog() 101 | 102 | const handleClick = useCallback(() => { 103 | setIsOpen(!isOpen) 104 | }, [isOpen, setIsOpen]) 105 | 106 | const handleKeyDown = useCallback( 107 | (event: React.KeyboardEvent) => { 108 | if (event.key === 'Enter' || event.key === ' ') { 109 | event.preventDefault() 110 | setIsOpen(!isOpen) 111 | } 112 | }, 113 | [isOpen, setIsOpen], 114 | ) 115 | 116 | return ( 117 | 130 | {children} 131 | 132 | ) 133 | } 134 | 135 | export type MorphingDialogContentProps = { 136 | children: React.ReactNode 137 | className?: string 138 | style?: React.CSSProperties 139 | } 140 | 141 | function MorphingDialogContent({ 142 | children, 143 | className, 144 | style, 145 | }: MorphingDialogContentProps) { 146 | const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog() 147 | const containerRef = useRef(null!) 148 | const [firstFocusableElement, setFirstFocusableElement] = 149 | useState(null) 150 | const [lastFocusableElement, setLastFocusableElement] = 151 | useState(null) 152 | 153 | useEffect(() => { 154 | const handleKeyDown = (event: KeyboardEvent) => { 155 | if (event.key === 'Escape') { 156 | setIsOpen(false) 157 | } 158 | if (event.key === 'Tab') { 159 | if (!firstFocusableElement || !lastFocusableElement) return 160 | 161 | if (event.shiftKey) { 162 | if (document.activeElement === firstFocusableElement) { 163 | event.preventDefault() 164 | lastFocusableElement.focus() 165 | } 166 | } else { 167 | if (document.activeElement === lastFocusableElement) { 168 | event.preventDefault() 169 | firstFocusableElement.focus() 170 | } 171 | } 172 | } 173 | } 174 | 175 | document.addEventListener('keydown', handleKeyDown) 176 | 177 | return () => { 178 | document.removeEventListener('keydown', handleKeyDown) 179 | } 180 | }, [setIsOpen, firstFocusableElement, lastFocusableElement]) 181 | 182 | useEffect(() => { 183 | if (isOpen) { 184 | document.body.classList.add('overflow-hidden') 185 | const focusableElements = containerRef.current?.querySelectorAll( 186 | 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 187 | ) 188 | if (focusableElements && focusableElements.length > 0) { 189 | setFirstFocusableElement(focusableElements[0] as HTMLElement) 190 | setLastFocusableElement( 191 | focusableElements[focusableElements.length - 1] as HTMLElement, 192 | ) 193 | ;(focusableElements[0] as HTMLElement).focus() 194 | } 195 | } else { 196 | document.body.classList.remove('overflow-hidden') 197 | triggerRef.current?.focus() 198 | } 199 | }, [isOpen, triggerRef]) 200 | 201 | useClickOutside(containerRef, () => { 202 | if (isOpen) { 203 | setIsOpen(false) 204 | } 205 | }) 206 | 207 | return ( 208 | 218 | {children} 219 | 220 | ) 221 | } 222 | 223 | export type MorphingDialogContainerProps = { 224 | children: React.ReactNode 225 | className?: string 226 | style?: React.CSSProperties 227 | } 228 | 229 | function MorphingDialogContainer({ children }: MorphingDialogContainerProps) { 230 | const { isOpen, uniqueId } = useMorphingDialog() 231 | const [mounted, setMounted] = useState(false) 232 | 233 | useEffect(() => { 234 | setMounted(true) 235 | return () => setMounted(false) 236 | }, []) 237 | 238 | if (!mounted) return null 239 | 240 | return createPortal( 241 | 242 | {isOpen && ( 243 | <> 244 | 251 |
252 | {children} 253 |
254 | 255 | )} 256 |
, 257 | document.body, 258 | ) 259 | } 260 | 261 | export type MorphingDialogTitleProps = { 262 | children: React.ReactNode 263 | className?: string 264 | style?: React.CSSProperties 265 | } 266 | 267 | function MorphingDialogTitle({ 268 | children, 269 | className, 270 | style, 271 | }: MorphingDialogTitleProps) { 272 | const { uniqueId } = useMorphingDialog() 273 | 274 | return ( 275 | 281 | {children} 282 | 283 | ) 284 | } 285 | 286 | export type MorphingDialogSubtitleProps = { 287 | children: React.ReactNode 288 | className?: string 289 | style?: React.CSSProperties 290 | } 291 | 292 | function MorphingDialogSubtitle({ 293 | children, 294 | className, 295 | style, 296 | }: MorphingDialogSubtitleProps) { 297 | const { uniqueId } = useMorphingDialog() 298 | 299 | return ( 300 | 305 | {children} 306 | 307 | ) 308 | } 309 | 310 | export type MorphingDialogDescriptionProps = { 311 | children: React.ReactNode 312 | className?: string 313 | disableLayoutAnimation?: boolean 314 | variants?: { 315 | initial: Variant 316 | animate: Variant 317 | exit: Variant 318 | } 319 | } 320 | 321 | function MorphingDialogDescription({ 322 | children, 323 | className, 324 | variants, 325 | disableLayoutAnimation, 326 | }: MorphingDialogDescriptionProps) { 327 | const { uniqueId } = useMorphingDialog() 328 | 329 | return ( 330 | 344 | {children} 345 | 346 | ) 347 | } 348 | 349 | export type MorphingDialogImageProps = { 350 | src: string 351 | alt: string 352 | className?: string 353 | style?: React.CSSProperties 354 | } 355 | 356 | function MorphingDialogImage({ 357 | src, 358 | alt, 359 | className, 360 | style, 361 | }: MorphingDialogImageProps) { 362 | const { uniqueId } = useMorphingDialog() 363 | 364 | return ( 365 | 372 | ) 373 | } 374 | 375 | export type MorphingDialogCloseProps = { 376 | children?: React.ReactNode 377 | className?: string 378 | variants?: { 379 | initial: Variant 380 | animate: Variant 381 | exit: Variant 382 | } 383 | } 384 | 385 | function MorphingDialogClose({ 386 | children, 387 | className, 388 | variants, 389 | }: MorphingDialogCloseProps) { 390 | const { setIsOpen, uniqueId } = useMorphingDialog() 391 | 392 | const handleClose = useCallback(() => { 393 | setIsOpen(false) 394 | }, [setIsOpen]) 395 | 396 | return ( 397 | 408 | {children || } 409 | 410 | ) 411 | } 412 | 413 | export { 414 | MorphingDialog, 415 | MorphingDialogTrigger, 416 | MorphingDialogContainer, 417 | MorphingDialogContent, 418 | MorphingDialogClose, 419 | MorphingDialogTitle, 420 | MorphingDialogSubtitle, 421 | MorphingDialogDescription, 422 | MorphingDialogImage, 423 | } 424 | --------------------------------------------------------------------------------