Add builder typewriter
This commit is contained in:
@@ -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
@@ -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
@@ -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'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">
|
||||
"Motion is the medium. Animation is not decoration, it's the point."
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user