Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/components/AnimatedName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function AnimatedName(props: AnimatedNameProps) {
<title id={id}>Sai Hari</title>
<g
transform="matrix(0.26458333,0,0,0.26458333,-36.721026,13.30472)"
className="stroke-[hsl(var(--color-gray-800))] stroke-[6]"
className="stroke-[hsl(var(--color-warm-black))] stroke-[6]"
>
{namePaths.map(({ letter, path }, index) => (
<motion.path
Expand All @@ -40,12 +40,12 @@ export default function AnimatedName(props: AnimatedNameProps) {
hidden: {
opacity: 0,
pathLength: 0,
fill: 'hsl(var(--color-white))',
fill: 'hsl(var(--color-cream))',
},
visible: {
opacity: 1,
pathLength: 1,
fill: 'hsl(var(--color-gray-700))',
fill: 'hsl(var(--color-warm-black))',
},
}}
initial="hidden"
Expand Down
84 changes: 40 additions & 44 deletions app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,9 @@
import { Link, NavLink, useMatches, type NavLinkProps } from '@remix-run/react';
import { Link, NavLink, useMatches } from '@remix-run/react';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { cls } from '~/utils/helpers';
import AnimatedName from './AnimatedName';

type AnimatedLinkProps = NavLinkProps & {
delayFactor: number;
};

function AnimatedLink({ delayFactor, ...props }: AnimatedLinkProps) {
return (
<motion.div
variants={{
hidden: { opacity: 0 },
visible: { opacity: 1 },
}}
initial="hidden"
animate="visible"
transition={{
delay: 0.25 * delayFactor,
default: { duration: 1.25, ease: 'easeInOut' },
opacity: { duration: 0.6, ease: [1, 0, 0.8, 1] },
}}
>
<NavLink
{...props}
className={({ isActive }) =>
cls(
'rtl inline-block text-lg font-medium transition-colors duration-200 after:block after:h-[2px] after:w-0 after:bg-gray-950 after:transition-[width] after:duration-200 hover:text-gray-950 hover:after:w-full',
{ 'text-gray-500': !isActive, 'text-gray-950': isActive },
)
}
/>
</motion.div>
);
}

export function Header() {
const match = useMatches();
const { id } = match[match.length - 1];
Expand All @@ -44,24 +12,52 @@ export function Header() {

return (
<div>
<nav
<motion.nav
className={cls(
'fixed left-0 right-0 top-0 z-50 flex h-16 items-center justify-between bg-white px-5 py-2 transition-shadow duration-75 ease-in md:px-24 lg:px-48',
'fixed left-0 right-0 top-0 z-50 flex h-20 items-center justify-between bg-cream/80 px-6 backdrop-blur-sm transition-all duration-200 md:px-12',
{
'shadow-[0px_24px_48px_rgb(0,0,0,.16)]': !!ref.current && !isInView,
'border-b border-warm-gray-200': !!ref.current && !isInView,
},
)}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<Link to="/" className="h-full">
<Link to="/" className="h-full py-4">
{id !== 'routes/_index' && <AnimatedName speed="normal" />}
</Link>
<div className="mt-3 flex gap-2">
<AnimatedLink to="/about" delayFactor={1}>
About
</AnimatedLink>
</div>
</nav>
<div ref={ref} className="h-16 w-full" />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<NavLink
to="/about"
className={({ isActive }) =>
cls('group relative text-base font-medium transition-colors', {
'text-warm-gray-400 hover:text-warm-black': !isActive,
'text-warm-black': isActive,
})
}
>
{({ isActive }) => (
<>
About
<span
className={cls(
'absolute -bottom-1 left-0 h-px bg-terracotta transition-all duration-300',
{
'w-0 group-hover:w-full': !isActive,
'w-full': isActive,
},
)}
/>
</>
)}
</NavLink>
</motion.div>
</motion.nav>
<div ref={ref} className="h-20 w-full" />
</div>
);
}
156 changes: 101 additions & 55 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { motion } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import AnimatedName from '~/components/AnimatedName';
import { IconLink } from '~/components/IconLink';
import { BlogIcon } from '~/icons/BlogIcon';
import { GitHubIcon } from '~/icons/GitHubIcon';
import { LinkedInIcon } from '~/icons/LinkedInIcon';
Expand All @@ -13,84 +13,130 @@ export const meta = () => {

export default function Index() {
return (
<main className="flex w-screen flex-1 flex-col items-center py-16 md:justify-center">
<div className="flex w-full max-w-screen-sm flex-col items-center gap-4 md:max-w-screen-md">
<h1 className="w-80 md:w-96 lg:w-[30rem]">
<AnimatedName speed="slow" />
</h1>
<motion.span
className="text-center text-xl font-bold text-gray-600 lg:text-2xl"
variants={{
hidden: { opacity: 0, translateY: '20px' },
visible: { opacity: 1, translateY: 0 },
}}
initial="hidden"
animate="visible"
transition={{
delay: 1,
default: { duration: 1.5, ease: 'easeInOut' },
opacity: { duration: 1, ease: [1, 0, 0.8, 1] },
}}
<main className="flex h-screen w-full flex-col items-center justify-center px-6 md:px-12">
<div className="flex w-full max-w-2xl flex-col items-center gap-12">
{/* Animated Name - Hero */}
<motion.div
className="w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<ClientOnly>
{() => <>{[...tagLines].sort(() => 0.5 - Math.random())[0]}</>}
</ClientOnly>
</motion.span>
<div className="mt-1 flex gap-5">
{iconConfigs.map((config, index) => (
<motion.div
key={config.title}
variants={{
hidden: { opacity: 0, translateY: `${30 + 5 * index}px` },
visible: { opacity: 1, translateY: 0 },
}}
initial="hidden"
animate="visible"
<h1 className="mx-auto w-full max-w-sm md:max-w-md lg:max-w-lg">
<AnimatedName speed="slow" />
</h1>
</motion.div>

{/* Tagline with cycling animation */}
<ClientOnly>{() => <CyclingTagline tagLines={tagLines} />}</ClientOnly>

{/* Social Links */}
<motion.nav
className="flex items-center gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.5, duration: 0.8, ease: 'easeOut' }}
>
{socialLinks.map((link, index) => (
<motion.a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="group relative flex flex-col items-center gap-2"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
delay: 1.2 + 0.1 * index,
default: { duration: 1.25, ease: 'easeInOut' },
opacity: { duration: 1, ease: [1, 0, 0.8, 1] },
delay: 1.6 + index * 0.1,
duration: 0.5,
ease: [0.34, 1.56, 0.64, 1],
}}
aria-label={link.label}
>
<IconLink
title={config.title}
Component={config.icon}
link={config.link}
/>
</motion.div>
<div className="relative">
<link.icon className="h-6 w-6 stroke-warm-black stroke-[1.5] transition-all duration-300 group-hover:scale-110 group-hover:stroke-terracotta" />
<span className="absolute -bottom-2 left-1/2 h-px w-0 -translate-x-1/2 bg-terracotta transition-all duration-300 group-hover:w-full" />
</div>
<span className="text-xs font-medium text-warm-gray-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
{link.label}
</span>
</motion.a>
))}
</div>
</motion.nav>

{/* Footer note */}
<motion.p
className="mt-6 text-center text-sm text-warm-gray-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2.2, duration: 1 }}
>
Developer. Builder. Thinker.
</motion.p>
</div>
</main>
);
}

function CyclingTagline({ tagLines }: { tagLines: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % tagLines.length);
}, 4000);
return () => clearInterval(interval);
}, [tagLines.length]);

return (
<motion.div
className="relative flex h-12 w-full items-center justify-center overflow-hidden md:h-16"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.2, duration: 0.8, ease: 'easeOut' }}
>
<AnimatePresence mode="wait">
<motion.p
key={currentIndex}
className="text-center text-xl font-light tracking-tight text-warm-gray-500 md:text-2xl lg:text-3xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
{tagLines[currentIndex]}
</motion.p>
</AnimatePresence>
</motion.div>
);
}

const tagLines = [
'Developer of Software',
'I think, therefore I program.',
'Level 4 Vim Sommelier',
'Fastest Code-Slinger in the West',
];

const iconConfigs = [
const socialLinks = [
{
title: 'twitter',
label: 'Twitter',
href: 'https://twitter.com/TheSSHGuy',
icon: TwitterIcon,
link: 'https://twitter.com/TheSSHGuy',
},
{
title: 'linkedin',
icon: LinkedInIcon,
link: 'https://www.linkedin.com/in/sshari/',
label: 'GitHub',
href: 'https://github.com/SSHari',
icon: GitHubIcon,
},
{
title: 'github',
icon: GitHubIcon,
link: 'https://github.com/SSHari',
label: 'LinkedIn',
href: 'https://www.linkedin.com/in/sshari/',
icon: LinkedInIcon,
},
{
title: 'blog',
label: 'Blog',
href: 'https://blog.thesshguy.com',
icon: BlogIcon,
link: 'https://blog.thesshguy.com',
},
];
Loading