From 47cb084e47036a62f7b39ebd0d7cf342a2a067ef Mon Sep 17 00:00:00 2001 From: luisdralves Date: Sat, 7 Mar 2026 00:43:04 +0000 Subject: [PATCH] Add builder typewriter --- src/app/globals.css | 1 + src/app/layout.tsx | 11 +- src/app/page.tsx | 49 +-------- .../about/builder-typewriter/dynamic.tsx | 101 ++++++++++++++++++ .../about/builder-typewriter/index.tsx | 22 ++++ .../builder-typewriter/reduced-motion.tsx | 34 ++++++ .../about/builder-typewriter/reducer.ts | 90 ++++++++++++++++ .../about/builder-typewriter/sequence.ts | 53 +++++++++ .../builder-typewriter/styles.module.css | 9 ++ src/app/sections/about/index.tsx | 37 +++++++ .../landing/geometric-element/index.tsx | 3 +- src/content/about.ts | 26 +++-- 12 files changed, 375 insertions(+), 61 deletions(-) create mode 100644 src/app/sections/about/builder-typewriter/dynamic.tsx create mode 100644 src/app/sections/about/builder-typewriter/index.tsx create mode 100644 src/app/sections/about/builder-typewriter/reduced-motion.tsx create mode 100644 src/app/sections/about/builder-typewriter/reducer.ts create mode 100644 src/app/sections/about/builder-typewriter/sequence.ts create mode 100644 src/app/sections/about/builder-typewriter/styles.module.css create mode 100644 src/app/sections/about/index.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 5a248a9..c9853de 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4d71a09..eda40c3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index b1449fd..31f0938 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {
-
-
-
-

About

-

- This is Outfit, the body font. It'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. -

-

- And this is Caveat, for handwritten notes and journal entries... -

-
- -
-

Color Palette

-
-
-
-

Background

- oklch(0.16 0.02 255) -
-
-
-

Foreground

- oklch(0.94 0.02 90) -
-
-
-

Accent Cyan

- oklch(0.75 0.14 195) -
-
-
-

Accent Violet

- oklch(0.58 0.19 300) -
-
-
- -
-

- "Motion is the medium. Animation is not decoration, it's the point." -

-
-
-
+
diff --git a/src/app/sections/about/builder-typewriter/dynamic.tsx b/src/app/sections/about/builder-typewriter/dynamic.tsx new file mode 100644 index 0000000..a864559 --- /dev/null +++ b/src/app/sections/about/builder-typewriter/dynamic.tsx @@ -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(null); + const cursorRef = useRef(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 ( +

+ {state.text.slice(0, state.cursor)} + {hasStarted && ( + + + + )} + {state.text.slice(state.cursor)} +

+ ); +}; diff --git a/src/app/sections/about/builder-typewriter/index.tsx b/src/app/sections/about/builder-typewriter/index.tsx new file mode 100644 index 0000000..72b2cea --- /dev/null +++ b/src/app/sections/about/builder-typewriter/index.tsx @@ -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 ; + } + + return ; +}; diff --git a/src/app/sections/about/builder-typewriter/reduced-motion.tsx b/src/app/sections/about/builder-typewriter/reduced-motion.tsx new file mode 100644 index 0000000..dfe2f37 --- /dev/null +++ b/src/app/sections/about/builder-typewriter/reduced-motion.tsx @@ -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(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 ( +

+ {prefix} + {suffixes[suffixIndex]} +

+ ); +}; diff --git a/src/app/sections/about/builder-typewriter/reducer.ts b/src/app/sections/about/builder-typewriter/reducer.ts new file mode 100644 index 0000000..e74072b --- /dev/null +++ b/src/app/sections/about/builder-typewriter/reducer.ts @@ -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; + } + } +}; diff --git a/src/app/sections/about/builder-typewriter/sequence.ts b/src/app/sections/about/builder-typewriter/sequence.ts new file mode 100644 index 0000000..0b0f364 --- /dev/null +++ b/src/app/sections/about/builder-typewriter/sequence.ts @@ -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 }; +}; diff --git a/src/app/sections/about/builder-typewriter/styles.module.css b/src/app/sections/about/builder-typewriter/styles.module.css new file mode 100644 index 0000000..25aa7b5 --- /dev/null +++ b/src/app/sections/about/builder-typewriter/styles.module.css @@ -0,0 +1,9 @@ +.cursor { + animation: cursor-blink 1s step-end infinite; +} + +@keyframes cursor-blink { + 50% { + opacity: 0; + } +} diff --git a/src/app/sections/about/index.tsx b/src/app/sections/about/index.tsx new file mode 100644 index 0000000..1a16273 --- /dev/null +++ b/src/app/sections/about/index.tsx @@ -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 ( +
+
+
+ +
+ +
+
+ {about.personalFacts.map((fact) => ( +
+

+ {fact.label} +

+

{fact.value}

+
+ ))} +
+
+ +
+

+ {about.transitionalStatement} +

+
+
+
+ ); +}; diff --git a/src/app/sections/landing/geometric-element/index.tsx b/src/app/sections/landing/geometric-element/index.tsx index 08215b3..e77385f 100644 --- a/src/app/sections/landing/geometric-element/index.tsx +++ b/src/app/sections/landing/geometric-element/index.tsx @@ -11,7 +11,6 @@ type Props = { export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh" }: Props) => { const shouldReduceMotion = useReducedMotion(); - const isReducedMotion = shouldReduceMotion ?? false; return (
@@ -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 ? : } + {shouldReduceMotion ? : }
); diff --git a/src/content/about.ts b/src/content/about.ts index 5df3962..1dbbe5b 100644 --- a/src/content/about.ts +++ b/src/content/about.ts @@ -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",