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",