
How to Integrate Three.js & Sanity CMS in my portfolio
El proceso de integrar Sanity como Content Manager System y Three.js una librerira para renderizar modelos 3D.
📌 Introduction
Have you ever wanted to combine interactive 3D graphics with a modern headless CMS? In this article, I’ll show you step by step how I integrated Three.js and Sanity CMS into my Next.js portfolio, creating an impressive visual experience and a flexible content management system.
🎯 What We’ll Achieve
- Interactive 3D model with Three.js (a PC that rotates and responds to mouse input)
- Free CMS with Sanity to manage projects
- Automatic image optimization with lazy loading
- Seamless integration between 3D components and dynamic content
- Optimized performance for both web and mobile
🛠️ Tech Stack
{
"frontend": "Next.js 13",
"3d": "Three.js",
"cms": "Sanity CMS",
"styling": "Chakra UI",
"deployment": "Vercel"
}
🎨 Part 1: Setting Up Three.js
1.1 Installing Dependencies
First, install the necessary dependencies for Three.js:
npm install three @types/three
# O si usas yarn
yarn add three @types/three
1.2 Creating the 3D Component
We’re going to create a component that renders a 3D model of a PC:
// components/voxel-pc.js
import { useState, useEffect, useRef, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { loadGLTFModel } from '../lib/model';
import { PcSpinner, PcContainer } from './voxel-pc-loader';
function easeOutCirc(x) {
return Math.sqrt(1 - Math.pow(x - 1, 4));
}
const VoxelPc = () => {
const refContainer = useRef();
const [loading, setLoading] = useState(true);
const refRenderer = useRef();
const urlPcGLB = '/pc.glb';
const handleWindowResize = useCallback(() => {
const { current: renderer } = refRenderer;
const { current: container } = refContainer;
if (container && renderer) {
const scW = container.clientWidth;
const scH = container.clientHeight;
renderer.setSize(scW, scH);
}
}, []);
useEffect(() => {
const { current: container } = refContainer;
if (container) {
const scW = container.clientWidth;
const scH = container.clientHeight;
// Set up renderer
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(scW, scH);
renderer.outputEncoding = THREE.sRGBEncoding;
container.appendChild(renderer.domElement);
refRenderer.current = renderer;
// Create scene
const scene = new THREE.Scene();
// Set up camera
const target = new THREE.Vector3(0, 0, 0);
const initialCameraPosition = new THREE.Vector3(
12 * Math.sin(0.2 * Math.PI),
6,
12 * Math.cos(0.2 * Math.PI)
);
const scale = scH * 0.003 + 2.2;
const camera = new THREE.OrthographicCamera(-scale, scale, scale, -scale, 0.01, 50000);
camera.position.copy(initialCameraPosition);
camera.lookAt(target);
// Add lighting
const ambientLight = new THREE.AmbientLight(0xcccccc, Math.PI);
scene.add(ambientLight);
// Set up controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.autoRotate = true;
controls.target = target;
// Load 3D model
loadGLTFModel(scene, urlPcGLB, {
receiveShadow: false,
castShadow: false,
})
.then(() => {
animate();
setLoading(false);
})
.catch(error => {
console.error('Error loading pc.glb:', error);
setLoading(false);
});
// Animation loop
let req = null;
let frame = 0;
const animate = () => {
req = requestAnimationFrame(animate);
frame = frame <= 100 ? frame + 1 : frame;
if (frame <= 100) {
const p = initialCameraPosition;
const rotSpeed = -easeOutCirc(frame / 120) * Math.PI * 20;
camera.position.y = 6;
camera.position.x = p.x * Math.cos(rotSpeed) + p.z * Math.sin(rotSpeed);
camera.position.z = p.z * Math.cos(rotSpeed) - p.x * Math.sin(rotSpeed);
camera.lookAt(target);
} else {
controls.update();
}
renderer.render(scene, camera);
};
// Cleanup
return () => {
cancelAnimationFrame(req);
renderer.domElement.remove();
renderer.dispose();
};
}
}, []);
// Handle resize
useEffect(() => {
window.addEventListener('resize', handleWindowResize, false);
return () => {
window.removeEventListener('resize', handleWindowResize, false);
};
}, [handleWindowResize]);
return <PcContainer ref={refContainer}>{loading && <PcSpinner />}</PcContainer>;
};
export default VoxelPc;
1.3 GLTF Model Loader
We create a helper to load 3D models:
// lib/model.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const draco = new DRACOLoader();
draco.setDecoderConfig({ type: 'js' });
draco.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
export function loadGLTFModel(scene, glbPath, options = { receiveShadow: true, castShadow: true }) {
const { receiveShadow, castShadow } = options;
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.setDRACOLoader(draco);
loader.load(
glbPath,
gltf => {
const obj = gltf.scene;
obj.name = 'pc';
obj.position.set(0, 0, 0);
obj.receiveShadow = receiveShadow;
obj.castShadow = castShadow;
// Escalar el modelo
obj.scale.setScalar(4.5);
scene.add(obj);
obj.traverse(function (child) {
if (child.isMesh) {
child.castShadow = castShadow;
child.receiveShadow = receiveShadow;
}
});
resolve(obj);
},
undefined,
error => reject(error)
);
});
}
1.4 Loading Component
// components/voxel-pc-loader.js
import { forwardRef } from 'react';
import { Box, Spinner } from '@chakra-ui/react';
export const PcSpinner = () => (
<Box position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)">
<Spinner size="xl" color="orange.400" />
</Box>
);
export const PcContainer = forwardRef(({ children }, ref) => (
<Box
ref={ref}
className="voxel-pc"
m="auto"
mt={['20px', '-60px', '-120px']}
mb={['-40px', '-140px', '-200px']}
w={[280, 480, 640]}
h={[280, 480, 640]}
position="relative"
>
{children}
</Box>
));
PcContainer.displayName = 'PcContainer';
🏗️ Part 2: Setting Up Sanity CMS
2.1 Installing Sanity
npm install @sanity/client sanity @sanity/image-url
2.2 Client Configuration
// lib/sanity.js
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
const config = {
projectId: 'tu-project-id', // Obtén esto desde sanity.io
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
token: process.env.SANITY_API_TOKEN,
};
const client = createClient(config);
const builder = imageUrlBuilder(client);
export const urlFor = source => {
if (!source) return null;
return builder.image(source).auto('format').fit('max');
};
export const getOptimizedImageUrl = (source, width = 800, height = null) => {
if (!source) return null;
let imageBuilder = builder.image(source).auto('format').fit('max').width(width);
if (height) {
imageBuilder = imageBuilder.height(height);
}
return imageBuilder.url();
};
export default client;
// Optimized queries
export const queries = {
projects: `*[_type == "project"] | order(order asc) {
_id,
title,
slug,
description,
year,
platform,
stack[0...3],
users,
roi,
growth,
order,
featured,
type,
image {
asset->{
_id,
url,
metadata {
dimensions {
width,
height
},
lqip
}
},
alt
}
}`,
project: `*[_type == "project" && slug.current == $slug][0] {
_id,
title,
slug,
description,
year,
platform,
stack,
features,
users,
roi,
growth,
website,
image {
asset->{
_id,
url,
metadata {
dimensions {
width,
height
},
lqip
}
},
alt
},
gallery[] {
asset->{
_id,
url,
metadata {
dimensions {
width,
height
},
lqip
}
},
alt,
caption
},
content,
type,
order
}`,
};
2.3 Environment Variables
# .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=tu-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=tu-api-token
2.4 Project Schema
// sanity/schemas/project.js
export default {
name: 'project',
title: 'Projects',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: Rule => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: Rule => Rule.required(),
},
{
name: 'description',
title: 'Description',
type: 'text',
validation: Rule => Rule.required(),
},
{
name: 'image',
title: 'Thumbnail Image',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
},
],
},
{
name: 'gallery',
title: 'Image Gallery',
type: 'array',
of: [
{
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
},
{
name: 'caption',
type: 'string',
title: 'Caption',
},
],
},
],
},
{
name: 'stack',
title: 'Tech Stack',
type: 'array',
of: [{ type: 'string' }],
options: {
layout: 'tags',
},
},
{
name: 'year',
title: 'Year',
type: 'number',
validation: Rule => Rule.required(),
},
{
name: 'type',
title: 'Project Type',
type: 'string',
options: {
list: [
{ title: 'Main Project', value: 'main' },
{ title: 'Collaboration', value: 'collaboration' },
{ title: 'Side Project', value: 'side' },
],
},
initialValue: 'main',
},
{
name: 'featured',
title: 'Featured',
type: 'boolean',
initialValue: false,
},
{
name: 'users',
title: 'Users/Downloads',
type: 'string',
description: 'e.g., "10K+ users", "5K downloads"',
},
{
name: 'roi',
title: 'ROI/Impact',
type: 'string',
description: 'e.g., "30% increase in sales"',
},
{
name: 'website',
title: 'Website URL',
type: 'url',
},
{
name: 'order',
title: 'Display Order',
type: 'number',
validation: Rule => Rule.required(),
},
],
preview: {
select: {
title: 'title',
media: 'image',
subtitle: 'year',
},
},
orderings: [
{
title: 'Display Order',
name: 'orderAsc',
by: [{ field: 'order', direction: 'asc' }],
},
],
};
🖼️ Part 3: Image Optimization
3.1 Optimized Image Component
// components/sanity-image.js
import Image from 'next/image';
import { urlFor } from '../lib/sanity';
const SanityImage = ({
image,
alt = '',
width = 800,
height = 600,
className = '',
priority = false,
quality = 85,
...props
}) => {
if (!image?.asset) {
return (
<div
className={`bg-gray-100 flex items-center justify-center ${className}`}
style={{ width, height }}
>
<span className="text-gray-400 text-sm">No image</span>
</div>
);
}
const imageUrl = urlFor(image).width(width).height(height).quality(quality).auto('format').url();
const blurDataURL =
image.asset.metadata?.lqip ||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBmaWxsPSIjRjNGNEY2Ii8+PC9zdmc+';
return (
<Image
src={imageUrl}
alt={alt || image.alt || 'Image'}
width={width}
height={height}
className={className}
priority={priority}
placeholder="blur"
blurDataURL={blurDataURL}
{...props}
/>
);
};
export default SanityImage;
3.2 Next.js Configuration
// next.config.js
module.exports = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: ['cdn.sanity.io'],
},
};
🔗 Part 4: Integrating Everything
4.1 Projects Page
// pages/works.js
import { Container, Heading, SimpleGrid, Divider } from '@chakra-ui/react';
import Layout from '../components/layouts/article';
import Section from '../components/section';
import { WorkGridItem } from '../components/grid-item';
import client, { queries, urlFor } from '../lib/sanity';
const Works = ({ projects = [] }) => {
if (projects.length === 0) {
return <div>No projects found</div>;
}
const mainProjects = projects.filter(p => p.type === 'main');
const collaborations = projects.filter(p => p.type === 'collaboration');
const sideProjects = projects.filter(p => p.type === 'side');
return (
<Layout title="Works">
<Container>
<Heading as="h3" fontSize={20} mb={4}>
Works
</Heading>
<SimpleGrid columns={[1, 1, 2]} gap={6}>
{mainProjects.map((project, index) => {
const thumbnail = project.image?.asset?.url
? urlFor(project.image).width(400).height(225).auto('format').quality(85).url()
: '/fallback-image.jpg';
return (
<Section key={project._id} delay={index * 0.1}>
<WorkGridItem id={project.slug.current} title={project.title} thumbnail={thumbnail}>
{project.description}
</WorkGridItem>
</Section>
);
})}
</SimpleGrid>
{collaborations.length > 0 && (
<>
<Section delay={0.2}>
<Divider my={6} />
<Heading as="h3" fontSize={20} mb={4}>
Collaborations
</Heading>
</Section>
<SimpleGrid columns={[1, 1, 2]} gap={6}>
{collaborations.map((project, index) => (
<Section key={project._id} delay={0.3 + index * 0.1}>
<WorkGridItem
id={project.slug.current}
title={project.title}
thumbnail={urlFor(project.image).width(400).height(225).url()}
>
{project.description}
</WorkGridItem>
</Section>
))}
</SimpleGrid>
</>
)}
</Container>
</Layout>
);
};
export async function getServerSideProps() {
try {
const projects = await client.fetch(queries.projects);
return {
props: { projects },
};
} catch (error) {
console.error('Failed to fetch projects from Sanity:', error);
return {
props: { projects: [] },
};
}
}
export default Works;
4.2 Home Page with 3D Model
// pages/index.js
import NextLink from 'next/link';
import {
Link,
Container,
Heading,
Box,
Button,
List,
ListItem,
useColorModeValue,
chakra,
} from '@chakra-ui/react';
import { ChevronRightIcon, EmailIcon } from '@chakra-ui/icons';
import Paragraph from '../components/paragraph';
import { BioSection, BioYear } from '../components/bio';
import Layout from '../components/layouts/article';
import Section from '../components/section';
import { GitHubIcon, TwitterIcon, LinkedInIcon } from '../components/icons';
import Image from 'next/image';
import { IoLogoTwitter, IoLogoInstagram, IoLogoGithub, IoLogoLinkedin } from 'react-icons/io5';
import VoxelPc from '../components/voxel-pc';
const ProfileImage = chakra(Image, {
shouldForwardProp: prop => ['width', 'height', 'src', 'alt'].includes(prop),
});
const Home = () => (
<Layout>
<Container>
<Box
borderRadius="lg"
mb={6}
p={3}
textAlign="center"
bg={useColorModeValue('whiteAlpha.500', 'whiteAlpha.200')}
css={{ backdropFilter: 'blur(10px)' }}
>
Hello, I'm an indie developer based in Rosario!
</Box>
<Box display={{ md: 'flex' }}>
<Box flexGrow={1}>
<Heading as="h2" variant="page-title">
Tomás Maritano
</Heading>
<p>Digital Craftsman ( Developer / Designer / Entrepreneur )</p>
</Box>
<Box flexShrink={0} mt={{ base: 4, md: 0 }} ml={{ md: 6 }} textAlign="center">
<Box
borderColor="whiteAlpha.800"
borderWidth={2}
borderStyle="solid"
w="100px"
h="100px"
display="inline-block"
borderRadius="full"
overflow="hidden"
>
<ProfileImage
src="/images/tomy.jpg"
alt="Profile image"
borderRadius="full"
width="100"
height="100"
/>
</Box>
</Box>
</Box>
<Section delay={0.1}>
<Heading as="h3" variant="section-title">
Work
</Heading>
<Paragraph>
Tomás is an indie developer with 7+ years scaling from Frontend Developer to Product
Manager at companies like Wolt, Unicoin, and Valere. He specializes in end-to-end product
development and team leadership.
</Paragraph>
<Box align="center" my={4}>
<Button
as={NextLink}
href="/works"
scroll={false}
rightIcon={<ChevronRightIcon />}
colorScheme="teal"
>
My portfolio
</Button>
</Box>
</Section>
<Section delay={0.2}>
<Heading as="h3" variant="section-title">
Bio
</Heading>
<BioSection>
<BioYear>1995</BioYear>
Born in Rosario, Argentina.
</BioSection>
<BioSection>
<BioYear>2018</BioYear>
Started as Frontend Developer
</BioSection>
<BioSection>
<BioYear>2022</BioYear>
Promoted to Product Manager at Wolt
</BioSection>
<BioSection>
<BioYear>2025 to present</BioYear>
Working as an indie developer
</BioSection>
</Section>
<Section delay={0.3}>
<Heading as="h3" variant="section-title">
On the web
</Heading>
<List>
<ListItem>
<Link href="https://github.com/tomymaritano" target="_blank">
<Button variant="ghost" colorScheme="teal" leftIcon={<IoLogoGithub />}>
@tomymaritano
</Button>
</Link>
</ListItem>
<ListItem>
<Link href="https://twitter.com/hacklabdog" target="_blank">
<Button variant="ghost" colorScheme="teal" leftIcon={<IoLogoTwitter />}>
@hacklabdog
</Button>
</Link>
</ListItem>
<ListItem>
<Link href="https://linkedin.com/in/tomymaritano" target="_blank">
<Button variant="ghost" colorScheme="teal" leftIcon={<IoLogoLinkedin />}>
@tomymaritano
</Button>
</Link>
</ListItem>
</List>
</Section>
<Section delay={0.3}>
<Box align="center" my={4}>
<VoxelPc />
</Box>
</Section>
</Container>
</Layout>
);
export default Home;
export { getServerSideProps } from '../components/chakra';
⚡ Part 5: Optimizations and Performance
5.1 Lazy Loading 3D Components
// components/lazy-voxel.js
import dynamic from 'next/dynamic';
import { PcSpinner, PcContainer } from './voxel-pc-loader';
const LazyVoxelPc = dynamic(() => import('./voxel-pc'), {
ssr: false,
loading: () => (
<PcContainer>
<PcSpinner />
</PcContainer>
),
});
export default LazyVoxelPc;
5.2 GROQ Query Optimization
// Queries optimizadas para diferentes casos de uso
export const optimizedQueries = {
// Para listados (solo datos esenciales)
projectsListing: `*[_type == "project"] | order(order asc) {
_id,
title,
slug,
description,
type,
"thumbnail": image.asset->url
}[0...12]`, // Limitar resultados
// Para página individual (datos completos)
projectDetail: `*[_type == "project" && slug.current == $slug][0] {
...,
"imageUrl": image.asset->url,
"galleryImages": gallery[].asset->url
}`,
// Para sitemap (solo slugs)
projectSlugs: `*[_type == "project"].slug.current`,
};
5.3 Implementing ISR (Incremental Static Regeneration)
// pages/works/[slug].js
export async function getStaticProps({ params }) {
const project = await client.fetch(queries.project, { slug: params.slug });
if (!project) {
return { notFound: true };
}
return {
props: { project },
revalidate: 60, // Revalidar cada minuto
};
}
export async function getStaticPaths() {
const slugs = await client.fetch(`*[_type == "project"].slug.current`);
return {
paths: slugs.map(slug => ({ params: { slug } })),
fallback: 'blocking',
};
}
🚀 Part 6: Deployment and Configuration
6.1 Vercel Configuration
// vercel.json
{
"functions": {
"pages/api/revalidate.js": {
"maxDuration": 10
}
},
"env": {
"NEXT_PUBLIC_SANITY_PROJECT_ID": "@sanity-project-id",
"SANITY_API_TOKEN": "@sanity-api-token"
}
}
6.2 Auto Revalidation Webhook
// pages/api/revalidate.js
export default async function handler(req, res) {
if (req.query.secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
try {
await res.revalidate('/works');
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send('Error revalidating');
}
}
6.3 Sanity Studio Configuration
// sanity.config.js
import { defineConfig } from 'sanity';
import { deskTool } from 'sanity/desk';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemas';
export default defineConfig({
name: 'default',
title: 'Portfolio CMS',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: 'production',
plugins: [deskTool(), visionTool()],
schema: {
types: schemaTypes,
},
tools: prev => {
if (process.env.NODE_ENV === 'development') {
return prev;
}
return prev.filter(tool => tool.name !== 'vision');
},
});
📊 Part 7: Metrics and Monitoring
7.1 Performance monitoring
// lib/analytics.js
export const trackModelLoad = (loadTime, modelSize) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', '3d_model_loaded', {
event_category: 'Performance',
event_label: 'PC Model',
value: Math.round(loadTime),
custom_parameter_1: modelSize,
});
}
};
export const trackCMSQuery = (queryType, responseTime) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'cms_query', {
event_category: 'CMS',
event_label: queryType,
value: Math.round(responseTime),
});
}
};
7.2 Error Boundary for 3D Components
// components/error-boundary.js
import React from 'react';
import { Box, Text, Button } from '@chakra-ui/react';
class ModelErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('3D Model Error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<Box textAlign="center" p={8}>
<Text mb={4}>Could not load 3D model</Text>
<Button onClick={() => this.setState({ hasError: false })}>Try Again</Button>
</Box>
);
}
return this.props.children;
}
}
export default ModelErrorBoundary;
🎯 Conclusions and Best Practices
✅ What We Achieved
- Impressive visual experience with interactive 3D models
- Completely free CMS with Sanity
- Optimized performance with lazy loading and ISR
- Scalability to grow with your portfolio
- SEO friendly with server-side rendering
🔧 Best Practices Implemented
- Lazy loading for heavy components
- Error boundaries for 3D components
- Automatic image optimization
- Efficient queries with GROQ
- TypeScript typing (optional)
- Performance monitoring
📈 Next Steps
- Implement PWA for improved mobile experience
- Add animations with Framer Motion
- A/B testing for 3D components
- Internationalization (i18n)
- Advanced analytics with event tracking
🔗 Useful Resources
💻 Full Code
All the code from this article is available on my GitHub.
Did you enjoy this tutorial? Follow me on Twitter for more content about web development and technology.
This article was created as part of my experience developing my personal portfolio. If you have any questions or suggestions, feel free to reach out.
Tags: three.js
sanity-cms
nextjs
react
3d-web
portfolio
javascript
webgl
title: ‘How I Built My Link-in-Bio with Vite, React & Framer Motion’ date: ‘2025-06-02’ excerpt: ‘Here’s the process of creating my animated link-in-bio using Vite, React, Tailwind CSS, and Framer Motion.’ coverImage: ‘/images/vite.png’
I wanted a minimal, animated link-in-bio that I could easily host and update myself — without relying on third-party services.
Here’s my process, tools, and what I learned along the way.
How I Built It — Step by Step
Here’s the exact process I followed to build this link-in-bio page:
1. Installed the Environment
First, I set up a new Vite + React project:
npm create vite@latest link-in-bio -- --template react
cd link-in-bio
Then I added the main dependencies:
npm install tailwindcss postcss autoprefixer framer-motion
npx tailwindcss init -p
And configured Tailwind in tailwind.config.js
and index.css
.
2. Created the Core Components
Next, I broke the UI into small reusable pieces:
ProfileImage.jsx
— top image + profile info.LinkButton.jsx
— each animated link.SocialLinks.jsx
— optional social icon row.
Each component was styled using Tailwind utility classes.
3. Integrated Animations with Framer Motion
I used Framer Motion for smooth interactions:
- Added
motion.a
to each link so they scale on hover. - Wrapped the entire layout in a
motion.div
to add a fade-in effect on page load.
4. Added a Dark Mode Toggle
I created a simple ToggleDarkMode.jsx
:
- Uses
useState
to toggle between dark and light modes. - Toggles a class on
html
orbody
to activate Tailwind’sdark:
variants. - Wrapped the entire app in a parent
div
withtransition-colors duration-500
for smooth transitions.
Finally
I wrapped the main layout in a motion.div
so that:
- On page mount, the opacity fades in.
- On theme toggle, a scale or fade transition gives a smooth experience.
This subtle polish made the app feel more dynamic and enjoyable to use.
Why I Built It Myself
Most link-in-bio tools look the same and charge a subscription fee.
I built my own because:
- ️Full control — content, design, hosting, and data.
- Performance — minimal footprint and lightning fast.
- Custom animations — a unique style that reflects my personal brand.
Tech Stack
Here’s what I used in this project:
- Vite — fast setup and instant HMR.
- React — familiar component-based structure.
- Tailwind CSS — utility-first styling with ease.
- Framer Motion — seamless page transitions and hover animations.
What I Learned
- Vite + Tailwind is a killer combo for fast development.
- Framer Motion makes animation fun and painless.
- Self-hosting means complete freedom and no recurring costs.