Add builder typewriter

This commit is contained in:
2026-03-07 00:43:04 +00:00
parent 37702f5fd3
commit 47cb084e47
12 changed files with 375 additions and 61 deletions
+1
View File
@@ -16,6 +16,7 @@
--font-heading: var(--font-heading);
--font-body: var(--font-body);
--font-cursive: var(--font-cursive);
--font-mono: var(--font-mono);
}
html {
+9 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Caveat, Outfit, Space_Grotesk } from "next/font/google";
import { Caveat, Courier_Prime, Outfit, Space_Grotesk } from "next/font/google";
import type { ReactNode } from "react";
import "./globals.css";
import { MotionProvider } from "@/components/motion-provider";
@@ -22,6 +22,13 @@ const caveat = Caveat({
display: "swap",
});
const courierPrime = Courier_Prime({
weight: "400",
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "luisdralves",
};
@@ -34,7 +41,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${spaceGrotesk.variable} ${outfit.variable} ${caveat.variable} font-body antialiased`}
className={`${spaceGrotesk.variable} ${outfit.variable} ${caveat.variable} ${courierPrime.variable} font-body antialiased`}
>
<MotionProvider>{children}</MotionProvider>
</body>
+2 -47
View File
@@ -1,3 +1,4 @@
import { About } from "@/app/sections/about";
import { Landing } from "@/app/sections/landing";
import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase";
import { ScrollProgressIndicator } from "@/components/scroll-progress-indicator";
@@ -10,53 +11,7 @@ export default function Home() {
<main>
<Landing />
<section id="about" className="px-8 py-16 md:px-16 lg:px-24">
<div className="mx-auto max-w-4xl space-y-16">
<div className="space-y-8">
<h2 className="font-bold font-heading text-5xl md:text-7xl">About</h2>
<p className="font-body text-xl leading-relaxed md:text-2xl">
This is Outfit, the body font. It&apos;s clean, modern, and highly readable across
all sizes. The neo-noir palette creates a cinematic feel with warm off-white text on
deep dark blue-grey.
</p>
<p className="font-cursive text-3xl md:text-4xl">
And this is Caveat, for handwritten notes and journal entries...
</p>
</div>
<div className="space-y-6">
<h3 className="font-heading font-semibold text-3xl">Color Palette</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<div className="h-24 rounded-lg border border-foreground/20 bg-background" />
<p className="font-body text-sm">Background</p>
<code className="text-xs opacity-60">oklch(0.16 0.02 255)</code>
</div>
<div className="space-y-2">
<div className="h-24 rounded-lg bg-foreground" />
<p className="font-body text-sm">Foreground</p>
<code className="text-xs opacity-60">oklch(0.94 0.02 90)</code>
</div>
<div className="space-y-2">
<div className="h-24 rounded-lg bg-accent-cyan" />
<p className="font-body text-sm">Accent Cyan</p>
<code className="text-xs opacity-60">oklch(0.75 0.14 195)</code>
</div>
<div className="space-y-2">
<div className="h-24 rounded-lg bg-accent-violet" />
<p className="font-body text-sm">Accent Violet</p>
<code className="text-xs opacity-60">oklch(0.58 0.19 300)</code>
</div>
</div>
</div>
<blockquote className="border-accent-cyan border-l-4 pl-6 italic">
<p className="font-body text-xl">
&quot;Motion is the medium. Animation is not decoration, it&apos;s the point.&quot;
</p>
</blockquote>
</div>
</section>
<About />
<section id="projects" className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
<div className="mx-auto max-w-4xl space-y-16">
@@ -0,0 +1,101 @@
"use client";
import { useInView } from "motion/react";
import { useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
import { initialState, reducer } from "./reducer";
import type { KeySequence } from "./sequence";
import styles from "./styles.module.css";
const HANDLED_KEYS = [
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"Enter",
"PageUp",
"PageDown",
];
type DynamicTypewriterProps = {
sequence: KeySequence;
};
export const DynamicTypewriter = ({ sequence }: DynamicTypewriterProps) => {
const ref = useRef<HTMLParagraphElement>(null);
const cursorRef = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { amount: 1 });
const [state, dispatch] = useReducer(reducer, initialState);
const [autoTick, setAutoTick] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
const sequenceIndex = useRef(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional restart on iter change
useLayoutEffect(() => {
const el = cursorRef.current;
if (!el) return;
el.style.animation = "none";
void el.offsetHeight;
el.style.animation = "";
}, [state.iter]);
useEffect(() => {
if (!isInView) return;
if (!hasStarted) setHasStarted(true);
const currentIndex = sequenceIndex.current;
const currentKey = sequence.keys[currentIndex];
const delay = sequence.delays[currentIndex];
const timeout = setTimeout(() => {
dispatch({ type: "KEY", key: currentKey });
sequenceIndex.current = currentIndex + 1;
if (sequenceIndex.current >= sequence.keys.length) {
sequenceIndex.current = sequence.loopStart;
}
setAutoTick(autoTick + 1);
}, delay);
return () => clearTimeout(timeout);
}, [isInView, sequence, autoTick, hasStarted]);
useEffect(() => {
if (!isInView) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (HANDLED_KEYS.includes(e.key) || e.key.length === 1) {
e.preventDefault();
dispatch({ type: "KEY", key: e.key });
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isInView]);
return (
<p
ref={ref}
className="min-h-[2.5em] whitespace-pre-wrap font-mono text-2xl leading-[1.2] md:text-3xl"
>
{state.text.slice(0, state.cursor)}
{hasStarted && (
<span className="relative">
<span
ref={cursorRef}
className={`-top-0.5 absolute bottom-0.5 left-0 w-[0.6em] bg-foreground mix-blend-difference ${styles.cursor}`}
/>
</span>
)}
{state.text.slice(state.cursor)}
</p>
);
};
@@ -0,0 +1,22 @@
"use client";
import { useReducedMotion } from "motion/react";
import { DynamicTypewriter } from "./dynamic";
import { ReducedMotionTypewriter } from "./reduced-motion";
import type { KeySequence } from "./sequence";
type BuilderTypewriterProps = {
prefix: string;
suffixes: readonly string[];
sequence: KeySequence;
};
export const BuilderTypewriter = ({ prefix, suffixes, sequence }: BuilderTypewriterProps) => {
const prefersReducedMotion = useReducedMotion();
if (prefersReducedMotion) {
return <ReducedMotionTypewriter prefix={prefix} suffixes={suffixes} />;
}
return <DynamicTypewriter sequence={sequence} />;
};
@@ -0,0 +1,34 @@
"use client";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
const CYCLE_SPEED = 3000;
type ReducedMotionTypewriterProps = {
prefix: string;
suffixes: readonly string[];
};
export const ReducedMotionTypewriter = ({ prefix, suffixes }: ReducedMotionTypewriterProps) => {
const ref = useRef<HTMLParagraphElement>(null);
const isInView = useInView(ref, { amount: 1 });
const [suffixIndex, setSuffixIndex] = useState(0);
useEffect(() => {
if (!isInView) return;
const interval = setInterval(() => {
setSuffixIndex((i) => (i + 1) % suffixes.length);
}, CYCLE_SPEED);
return () => clearInterval(interval);
}, [isInView, suffixes.length]);
return (
<p ref={ref} className="min-h-[2.5em] font-mono text-2xl leading-[1.2] md:text-3xl">
<span>{prefix}</span>
<span>{suffixes[suffixIndex]}</span>
</p>
);
};
@@ -0,0 +1,90 @@
export type State = {
text: string;
cursor: number;
iter: number;
};
export type Action = { type: "KEY"; key: string };
export const initialState: State = {
text: "",
cursor: 0,
iter: 0,
};
export const reducer = (state: State, action: Action): State => {
const { text, cursor, iter } = state;
const nextIter = iter + 1;
switch (action.key) {
case "Backspace": {
if (cursor === 0) return state;
return {
text: text.slice(0, cursor - 1) + text.slice(cursor),
cursor: cursor - 1,
iter: nextIter,
};
}
case "Delete": {
if (cursor === text.length) return state;
return {
text: text.slice(0, cursor) + text.slice(cursor + 1),
cursor,
iter: nextIter,
};
}
case "ArrowLeft": {
if (cursor === 0) return state;
return { ...state, cursor: cursor - 1, iter: nextIter };
}
case "ArrowRight": {
if (cursor === text.length) return state;
return { ...state, cursor: cursor + 1, iter: nextIter };
}
case "Home": {
if (cursor === 0) return state;
return { ...state, cursor: 0, iter: nextIter };
}
case "End": {
if (cursor === text.length) return state;
return { ...state, cursor: text.length, iter: nextIter };
}
case "PageUp": {
const prevNewline = text.lastIndexOf("\n", cursor - 1);
const newCursor = prevNewline + 1;
if (newCursor === cursor) return state;
return { ...state, cursor: newCursor, iter: nextIter };
}
case "PageDown": {
const nextNewline = text.indexOf("\n", cursor);
const newCursor = nextNewline === -1 ? text.length : nextNewline;
if (newCursor === cursor) return state;
return { ...state, cursor: newCursor, iter: nextIter };
}
case "Enter":
return {
text: `${text.slice(0, cursor)}\n${text.slice(cursor)}`,
cursor: cursor + 1,
iter: nextIter,
};
default: {
if (action.key.length === 1) {
return {
text: text.slice(0, cursor) + action.key + text.slice(cursor),
cursor: cursor + 1,
iter: nextIter,
};
}
return state;
}
}
};
@@ -0,0 +1,53 @@
const TYPE_MEAN = 50;
const TYPE_STDDEV = 35;
const DELETE_DELAY = 30;
const PAUSE_DELAY = 2000;
const randomDelay = (mean: number, stddev: number): number => {
const u1 = Math.random() || Number.MIN_VALUE;
const u2 = Math.random();
const normal = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return Math.max(10, Math.round(mean + normal * stddev));
};
export type KeySequence = {
keys: string[];
loopStart: number;
delays: number[];
};
export const generateKeySequence = (prefix: string, suffixes: readonly string[]): KeySequence => {
const keys: string[] = [];
for (const char of prefix) {
keys.push(char);
}
const loopStart = keys.length;
for (const suffix of suffixes) {
for (const char of suffix) {
keys.push(char);
}
for (let i = 0; i < suffix.length; i++) {
keys.push("Backspace");
}
}
const delays = keys.map((key, i) => {
const prevKey = i > 0 ? keys[i - 1] : null;
const isBackspace = key === "Backspace";
if (prevKey !== null && prevKey !== "Backspace" && isBackspace) {
return PAUSE_DELAY;
}
if (isBackspace) {
return DELETE_DELAY;
}
return randomDelay(TYPE_MEAN, TYPE_STDDEV);
});
return { keys, loopStart, delays };
};
@@ -0,0 +1,9 @@
.cursor {
animation: cursor-blink 1s step-end infinite;
}
@keyframes cursor-blink {
50% {
opacity: 0;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { about } from "@/content/about";
import { BuilderTypewriter } from "./builder-typewriter";
import { generateKeySequence } from "./builder-typewriter/sequence";
export const About = () => {
const { prefix, suffixes } = about.builderPhrase;
const sequence = generateKeySequence(prefix, suffixes);
return (
<section id="about" className="px-8 py-16 md:px-16 lg:px-24">
<div className="mx-auto max-w-4xl space-y-16">
<div className="space-y-6">
<BuilderTypewriter prefix={prefix} suffixes={suffixes} sequence={sequence} />
</div>
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{about.personalFacts.map((fact) => (
<div key={fact.label} className="space-y-1">
<p className="font-body text-sm uppercase tracking-wider opacity-60">
{fact.label}
</p>
<p className="font-body text-lg">{fact.value}</p>
</div>
))}
</div>
</div>
<div>
<p className="font-cursive text-3xl text-accent-cyan md:text-4xl">
{about.transitionalStatement}
</p>
</div>
</div>
</section>
);
};
@@ -11,7 +11,6 @@ type Props = {
export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh" }: Props) => {
const shouldReduceMotion = useReducedMotion();
const isReducedMotion = shouldReduceMotion ?? false;
return (
<div className="relative w-full overflow-visible" style={{ height: wrapperHeight }}>
@@ -19,7 +18,7 @@ export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh"
className="-z-10 -translate-y-1/2 mask-[linear-gradient(to_bottom,transparent,black_50%,transparent)] absolute inset-x-0 top-1/2"
style={{ height: canvasHeight }}
>
{isReducedMotion ? <StaticFallback /> : <DynamicCanvas />}
{shouldReduceMotion ? <StaticFallback /> : <DynamicCanvas />}
</div>
</div>
);
+16 -10
View File
@@ -1,6 +1,9 @@
export type AboutContent = {
builderPhrases: string[];
personalFacts: {
builderPhrase: {
prefix: string;
suffixes: readonly string[];
};
personalFacts: readonly {
label: string;
value: string;
}[];
@@ -8,14 +11,17 @@ export type AboutContent = {
};
export const about = {
builderPhrases: [
"Building systems that connect",
"Building to preserve",
"Building new ways to see familiar things",
"Building community",
"Building understanding",
"Building a bloated sense of self-importance",
],
builderPhrase: {
prefix: "Building",
suffixes: [
" systems that connect",
" to preserve",
" new ways to see familiar things",
" community",
" understanding",
" a bloated sense of self-importance",
],
},
personalFacts: [
{
label: "Location",