← Back home

Freebies

Components from this very site, cleaned up so you can use them in your own projects. Play with the live preview, copy the code, or grab the whole folder as a zip.

Best on a bigger screen

These components come with live previews and code you can copy, which is a lot to squeeze onto a phone. Open this page on your laptop or desktop to grab them.

Back to the main site
01

Road Journey

A winding road that draws itself as you scroll, with a little car driving between your milestones. Works with the page or any scroll container.

Zero dependencies
RoadJourney.tsx

Scroll inside this window ↓

  1. Stop 01

    Hello, world

    Wrote my first line of code and never looked back.

  2. Stop 02

    First client

    Someone actually paid me to build a thing.

  3. Stop 03

    Big project

    Shipped something thousands of people use.

  4. Up next

    Next exit

    Working on it right now.

1"use client";
2
3import { RefObject, useEffect, useRef, useState } from "react";
4import "./RoadJourney.css";
5
6export type Stop = { title: string; note: string; kicker?: string };
7
8type Props = {
9  stops: Stop[];
10  scroller?: RefObject<HTMLElement | null>;
11};
12
13type Point = { x: number; y: number };
14
15type Geometry = {
16  w: number;
17  h: number;
18  road: number;
19  points: Point[];
20  d: string;
21  segments: string[];
22  end: Point;
23};
24
25function curve(a: Point, b: Point) {
26  const k = (b.y - a.y) * 0.55;
27  return `C ${a.x} ${a.y + k}, ${b.x} ${b.y - k}, ${b.x} ${b.y}`;
28}
29
30export default function RoadJourney({ stops, scroller }: Props) {
31  const map = useRef<HTMLDivElement>(null);
32  const list = useRef<HTMLOListElement>(null);
33  const segRef = useRef<SVGGElement>(null);
34  const car = useRef<HTMLDivElement>(null);
35  const [geo, setGeo] = useState<Geometry | null>(null);
36
37  useEffect(() => {
38    const el = map.current;
39    const ol = list.current;
40    if (!el || !ol) return;
41
42    const measure = () => {
43      const w = el.clientWidth;
44      const h = el.clientHeight;
45      const narrow = w < 640;
46      const road = narrow ? 16 : 28;
47      const amp = narrow ? w * 0.035 : Math.min(80, w * 0.06);
48      const items = Array.from(ol.children) as HTMLElement[];
49      const lanes = items.map((_, i) =>
50        i % 2 === 0 ? w * (narrow ? 0.885 : 0.7) : w * (narrow ? 0.115 : 0.3),
51      );
52      const points = items.map((item, i) => ({
53        x: lanes[i] + (i % 2 === 0 ? amp : -amp),
54        y: item.offsetTop + item.offsetHeight / 2,
55      }));
56      const route = items.flatMap((item, i) => [
57        { x: lanes[i], y: item.offsetTop + 10 },
58        points[i],
59        { x: lanes[i], y: item.offsetTop + item.offsetHeight - 10 },
60      ]);
61      const last = items[items.length - 1];
62      const end = {
63        x: lanes[lanes.length - 1],
64        y: Math.min(h, last.offsetTop + last.offsetHeight + 80),
65      };
66      const all = [{ x: lanes[0], y: 0 }, ...route, end];
67      const segments = all
68        .slice(1)
69        .map((b, i) => `M ${all[i].x} ${all[i].y} ${curve(all[i], b)}`);
70      setGeo({
71        w,
72        h,
73        road,
74        points,
75        end,
76        segments,
77        d:
78          `M ${all[0].x} ${all[0].y} ` +
79          all
80            .slice(1)
81            .map((b, i) => curve(all[i], b))
82            .join(" "),
83      });
84    };
85
86    measure();
87    const ro = new ResizeObserver(measure);
88    ro.observe(el);
89    return () => ro.disconnect();
90  }, [stops]);
91
92  useEffect(() => {
93    const el = map.current;
94    const rig = car.current;
95    const group = segRef.current;
96    if (!geo || !el || !rig || !group) return;
97
98    const segs = Array.from(group.querySelectorAll<SVGPathElement>("path"));
99    const segLen = segs.map((p) => p.getTotalLength());
100    const segStart = segLen.map((_, i) =>
101      segLen.slice(0, i).reduce((a, b) => a + b, 0),
102    );
103    const xs: number[] = [];
104    const ys: number[] = [];
105    const lens: number[] = [];
106    segs.forEach((p, s) => {
107      const n = Math.max(6, Math.ceil(segLen[s] / 8));
108      for (let i = s === 0 ? 0 : 1; i <= n; i++) {
109        const pt = p.getPointAtLength((segLen[s] * i) / n);
110        xs.push(pt.x);
111        ys.push(pt.y);
112        lens.push(segStart[s] + (segLen[s] * i) / n);
113      }
114      p.style.strokeDasharray = `${segLen[s] + 1}`;
115      p.style.strokeDashoffset = `${segLen[s] + 1}`;
116    });
117    const N = ys.length - 1;
118    const angles = xs.map((_, i) => {
119      const j = Math.min(N, i + 1);
120      const k = j === i ? i - 1 : i;
121      return Math.atan2(ys[j] - ys[k], xs[j] - xs[k]);
122    });
123
124    const items = Array.from(list.current?.children ?? []) as HTMLElement[];
125    const lane = geo.road * 0.24;
126    const parkY = geo.end.y - geo.road * 2.5;
127    let current = -1;
128    let reached = -1;
129    let frame = 0;
130
131    const update = () => {
132      frame = 0;
133      const box = scroller?.current;
134      const viewTop = box ? box.getBoundingClientRect().top : 0;
135      const viewH = box ? box.clientHeight : window.innerHeight;
136      const raw = viewH * 0.6 - (el.getBoundingClientRect().top - viewTop);
137      const y = Math.min(parkY, raw);
138
139      let lo = 0;
140      let hi = N;
141      if (y <= ys[0]) hi = 1;
142      else if (y >= ys[N]) lo = N - 1;
143      else
144        while (hi - lo > 1) {
145          const mid = (lo + hi) >> 1;
146          if (ys[mid] < y) lo = mid;
147          else hi = mid;
148        }
149      const t = Math.min(1, Math.max(0, (y - ys[lo]) / (ys[hi] - ys[lo] || 1)));
150      const px = xs[lo] + (xs[hi] - xs[lo]) * t;
151      const py = ys[lo] + (ys[hi] - ys[lo]) * t;
152      const a = angles[lo] + (angles[hi] - angles[lo]) * t;
153      const len = lens[lo] + (lens[hi] - lens[lo]) * t;
154
155      rig.style.transform = `translate3d(${px + Math.sin(a) * lane}px, ${py - Math.cos(a) * lane}px, 0) rotate(${(a * 180) / Math.PI}deg)`;
156
157      let k = segStart.length - 1;
158      while (k > 0 && segStart[k] > len) k--;
159      if (k !== current) {
160        segs.forEach((p, i) => {
161          if (i < k) p.style.strokeDashoffset = "0";
162          else if (i > k) p.style.strokeDashoffset = `${segLen[i] + 1}`;
163        });
164        current = k;
165      }
166      segs[k].style.strokeDashoffset =
167        `${segLen[k] + 1 - Math.min(segLen[k], len - segStart[k])}`;
168
169      let count = 0;
170      while (count < geo.points.length && py >= geo.points[count].y - 4)
171        count++;
172      if (count !== reached) {
173        reached = count;
174        items.forEach((item, i) =>
175          item.classList.toggle("is-reached", i < count),
176        );
177      }
178    };
179
180    const schedule = () => {
181      if (!frame) frame = requestAnimationFrame(update);
182    };
183
184    update();
185    const target: HTMLElement | Window = scroller?.current ?? window;
186    target.addEventListener("scroll", schedule, { passive: true });
187    window.addEventListener("resize", schedule);
188    return () => {
189      cancelAnimationFrame(frame);
190      target.removeEventListener("scroll", schedule);
191      window.removeEventListener("resize", schedule);
192    };
193  }, [geo, scroller]);
194
195  return (
196    <div className="rj" ref={map}>
197      {geo && (
198        <svg
199          className="rj__road"
200          width={geo.w}
201          height={geo.h}
202          viewBox={`0 0 ${geo.w} ${geo.h}`}
203          aria-hidden="true"
204        >
205          <defs>
206            <linearGradient id="rj-fade" x1="0" y1="0" x2="0" y2="1">
207              <stop
208                offset="0"
209                style={{ stopColor: "var(--rj-bg)", stopOpacity: 0 }}
210              />
211              <stop offset="1" style={{ stopColor: "var(--rj-bg)" }} />
212            </linearGradient>
213          </defs>
214          <path
215            d={geo.d}
216            className="rj__asphalt rj__asphalt--ghost"
217            strokeWidth={geo.road}
218          />
219          <g ref={segRef}>
220            {geo.segments.map((d, i) => (
221              <path
222                key={i}
223                d={d}
224                className="rj__asphalt"
225                strokeWidth={geo.road + 0.5}
226              />
227            ))}
228          </g>
229          <path
230            d={geo.d}
231            className="rj__lines"
232            strokeWidth={geo.road < 20 ? 1.5 : 2}
233          />
234          <rect
235            x={geo.end.x - geo.road * 2}
236            y={geo.end.y - 110}
237            width={geo.road * 4}
238            height={120}
239            fill="url(#rj-fade)"
240          />
241        </svg>
242      )}
243
244      <ol className="rj__stops" ref={list}>
245        {stops.map((s, i) => (
246          <li
247            key={s.title}
248            className={`rj__stop ${i % 2 === 0 ? "is-left" : "is-right"}`}
249          >
250            <div className="rj__card">
251              <span className="rj__kicker">
252                {s.kicker ?? `Stop ${String(i + 1).padStart(2, "0")}`}
253              </span>
254              <h3 className="rj__title">{s.title}</h3>
255              <p className="rj__note">{s.note}</p>
256            </div>
257          </li>
258        ))}
259      </ol>
260
261      {geo && (
262        <div className="rj__car" ref={car} aria-hidden="true">
263          <svg viewBox="-16 -10 32 20" width={geo.road < 20 ? 22 : 34}>
264            <rect x="-9" y="-8" width="4.5" height="2.5" rx="1" fill="#111" />
265            <rect x="4" y="-8" width="4.5" height="2.5" rx="1" fill="#111" />
266            <rect x="-9" y="5.5" width="4.5" height="2.5" rx="1" fill="#111" />
267            <rect x="4" y="5.5" width="4.5" height="2.5" rx="1" fill="#111" />
268            <rect
269              x="-11"
270              y="-6.5"
271              width="22"
272              height="13"
273              rx="5"
274              fill="var(--rj-accent)"
275            />
276            <rect x="2" y="-4.8" width="5" height="9.6" rx="1.5" fill="#111" />
277            <circle cx="10.5" cy="-3.5" r="1.1" fill="#fff3c4" />
278            <circle cx="10.5" cy="3.5" r="1.1" fill="#fff3c4" />
279          </svg>
280        </div>
281      )}
282    </div>
283  );
284}
02

Preloader

A loading screen with rotating highlighted words, popping photos, a counter and a curtain exit. Full screen, or contained inside any box.

Preloader.tsx

Your site, loaded ✓

1"use client";
2
3import { useRef, useState } from "react";
4import gsap from "gsap";
5import { useGSAP } from "@gsap/react";
6import "./Preloader.css";
7
8gsap.registerPlugin(useGSAP);
9
10type Props = {
11  lead?: string;
12  words?: string[];
13  images?: string[];
14  duration?: number;
15  contained?: boolean;
16  onDone?: () => void;
17};
18
19const SPOTS: [number, number, number][] = [
20  [14, 22, -8],
21  [84, 20, 7],
22  [10, 72, -5],
23  [88, 68, 9],
24  [36, 11, -10],
25  [62, 86, 6],
26];
27
28export default function Preloader({
29  lead = "creating incredible",
30  words = ["websites", "backends", "experiences"],
31  images = [],
32  duration = 3.2,
33  contained = false,
34  onDone,
35}: Props) {
36  const root = useRef<HTMLDivElement>(null);
37  const [done, setDone] = useState(false);
38  const longest = words.reduce((a, b) => (b.length > a.length ? b : a), "");
39
40  useGSAP(
41    () => {
42      const counter = { v: 0 };
43      const num = root.current?.querySelector<HTMLElement>(".pl__count");
44      const render = () => {
45        if (num)
46          num.textContent = String(Math.round(counter.v)).padStart(3, "0");
47      };
48
49      const items = gsap.utils.toArray<HTMLElement>(".pl__word");
50      gsap.set(items, { y: 0, yPercent: 110 });
51      gsap.set(items[0], { yPercent: 0 });
52
53      const tl = gsap.timeline();
54      tl.fromTo(
55        ".pl__lead-in",
56        { y: 0, yPercent: 110 },
57        { yPercent: 0, duration: 0.9, ease: "expo.out" },
58      )
59        .fromTo(
60          ".pl__pill",
61          { scaleX: 0 },
62          { scaleX: 1, duration: 0.8, ease: "expo.out" },
63          0.1,
64        )
65        .to(
66          counter,
67          { v: 100, duration, ease: "power2.inOut", onUpdate: render },
68          0.2,
69        )
70        .fromTo(
71          ".pl__bar",
72          { scaleX: 0 },
73          { scaleX: 1, duration, ease: "power2.inOut" },
74          0.2,
75        );
76
77      const step = duration / items.length;
78      items.forEach((word, i) => {
79        if (i === 0) return;
80        const at = 0.2 + step * i;
81        tl.to(
82          items[i - 1],
83          { yPercent: -110, duration: 0.5, ease: "expo.inOut" },
84          at,
85        ).to(word, { yPercent: 0, duration: 0.5, ease: "expo.inOut" }, at);
86      });
87
88      const photos = gsap.utils.toArray<HTMLElement>(".pl__photo");
89      photos.forEach((photo, i) => {
90        const r = Number(photo.dataset.r);
91        tl.fromTo(
92          photo,
93          { scale: 0, rotation: r * 3, opacity: 0 },
94          {
95            scale: 1,
96            rotation: r,
97            opacity: 1,
98            duration: 0.7,
99            ease: "back.out(2)",
100          },
101          0.35 + (duration * 0.8 * i) / photos.length,
102        );
103      });
104
105      const exit = duration + 0.45;
106      tl.to(
107        photos,
108        {
109          scale: 0,
110          left: "50%",
111          top: "50%",
112          duration: 0.6,
113          ease: "power3.in",
114          stagger: 0.04,
115        },
116        exit,
117      )
118        .to(
119          ".pl__center, .pl__foot",
120          { yPercent: -60, opacity: 0, duration: 0.6, ease: "power3.in" },
121          exit + 0.1,
122        )
123        .to(
124          root.current,
125          {
126            yPercent: -100,
127            borderBottomLeftRadius: "50% 14%",
128            borderBottomRightRadius: "50% 14%",
129            duration: 1.2,
130            ease: "expo.inOut",
131          },
132          exit + 0.5,
133        )
134        .add(() => {
135          setDone(true);
136          onDone?.();
137        });
138    },
139    { scope: root },
140  );
141
142  if (done) return null;
143
144  return (
145    <div
146      ref={root}
147      className={`pl ${contained ? "pl--contained" : ""}`}
148      aria-hidden="true"
149    >
150      {images.slice(0, SPOTS.length).map((src, i) => (
151        <div
152          key={src}
153          className="pl__photo"
154          data-r={SPOTS[i][2]}
155          style={{ left: `${SPOTS[i][0]}%`, top: `${SPOTS[i][1]}%` }}
156        >
157          <img src={src} alt="" />
158        </div>
159      ))}
160
161      <div className="pl__center">
162        <p className="pl__lead">
163          <span className="pl__lead-in">{lead}</span>
164        </p>
165        <div className="pl__pill">
166          <span className="pl__sizer">{longest}</span>
167          {words.map((w) => (
168            <span key={w} className="pl__word">
169              {w}
170            </span>
171          ))}
172        </div>
173      </div>
174
175      <div className="pl__foot">
176        <span className="pl__num">
177          <span className="pl__count">000</span>
178          <span className="pl__pct">%</span>
179        </span>
180      </div>
181      <span className="pl__bar" />
182    </div>
183  );
184}
04

Slot Stats

Stat tiles whose digits roll into place like a slot machine when they scroll into view. Pure CSS animation, no libraries.

Zero dependencies
SlotStats.tsx
  • 301234567890123456789M+

    Total visits

  • 101234567890123456789001234567890123456789

    Client projects

  • $101234567890123456789201234567890123456789K+

    Saved for clients

  • 801234567890123456789501234567890123456789001234567890123456789

    Hours saved

1"use client";
2
3import { useEffect, useRef, useState } from "react";
4import "./SlotStats.css";
5
6export type Stat = {
7  value: string;
8  caption: string;
9  prefix?: string;
10  suffix?: string;
11  tone?: "ink" | "accent" | "soft" | "outline";
12};
13
14const CELLS = Array.from({ length: 20 }, (_, i) => i % 10);
15
16function Digit({ char, index }: { char: string; index: number }) {
17  if (!/\d/.test(char)) return <span className="ss__char">{char}</span>;
18  return (
19    <span
20      className="ss__roll"
21      style={
22        {
23          "--d": Number(char),
24          "--delay": `${index * 0.12}s`,
25          "--dur": `${1.4 + index * 0.35}s`,
26        } as React.CSSProperties
27      }
28    >
29      <span className="ss__ghost">{char}</span>
30      <span className="ss__strip">
31        {CELLS.map((n, i) => (
32          <span key={i}>{n}</span>
33        ))}
34      </span>
35    </span>
36  );
37}
38
39export default function SlotStats({ stats }: { stats: Stat[] }) {
40  const root = useRef<HTMLUListElement>(null);
41  const [visible, setVisible] = useState(false);
42
43  useEffect(() => {
44    const el = root.current;
45    if (!el) return;
46    const io = new IntersectionObserver(
47      ([entry]) => {
48        if (!entry.isIntersecting) return;
49        setVisible(true);
50        io.disconnect();
51      },
52      { threshold: 0.25 },
53    );
54    io.observe(el);
55    return () => io.disconnect();
56  }, []);
57
58  return (
59    <div className="ss-wrap">
60      <ul ref={root} className={`ss ${visible ? "is-in" : ""}`}>
61        {stats.map((s, i) => (
62          <li
63            key={s.caption}
64            className={`ss__tile ss__tile--${s.tone ?? "ink"}`}
65            style={{ "--i": i } as React.CSSProperties}
66          >
67            <p
68              className="ss__num"
69              aria-label={`${s.prefix ?? ""}${s.value}${s.suffix ?? ""}`}
70            >
71              {s.prefix && <span className="ss__affix">{s.prefix}</span>}
72              {s.value.split("").map((c, j) => (
73                <Digit key={j} char={c} index={j} />
74              ))}
75              {s.suffix && <span className="ss__affix">{s.suffix}</span>}
76            </p>
77            <p className="ss__caption">{s.caption}</p>
78          </li>
79        ))}
80      </ul>
81    </div>
82  );
83}
05

Stack Cards

Sticky project cards that stack on top of each other, with the one underneath shrinking back and dimming as it gets covered.

Zero dependencies
StackCards.tsx
01 / 032025

Project one

Two million visits and counting.

A short story about what this project is and why it matters.

  • Next.js
  • MongoDB
02 / 032026

Project two

From inbox chaos to one record per issue.

Each card sticks, and the one below slides over it.

  • Go
  • PostgreSQL
03 / 032026

Project three

Built in a weekend.

The card underneath shrinks back and dims as it gets covered.

  • TypeScript
1"use client";
2
3import { RefObject, useEffect, useRef } from "react";
4import "./StackCards.css";
5
6export type Card = {
7  title: string;
8  text: string;
9  year?: string;
10  hook?: string;
11  tags?: string[];
12  link?: { label: string; href: string };
13  tone?: "ink" | "accent" | "soft";
14};
15
16type Props = {
17  cards: Card[];
18  scroller?: RefObject<HTMLElement | null>;
19  cardHeight?: string;
20  top?: number;
21};
22
23export default function StackCards({
24  cards,
25  scroller,
26  cardHeight = "80vh",
27  top = 24,
28}: Props) {
29  const root = useRef<HTMLDivElement>(null);
30
31  useEffect(() => {
32    const el = root.current;
33    if (!el) return;
34    const items = Array.from(el.querySelectorAll<HTMLElement>(".sc-card"));
35    const inners = items.map((c) =>
36      c.querySelector<HTMLElement>(".sc-card__inner"),
37    );
38    const shades = items.map((c) =>
39      c.querySelector<HTMLElement>(".sc-card__shade"),
40    );
41    let frame = 0;
42
43    const update = () => {
44      frame = 0;
45      const box = scroller?.current;
46      const viewTop = box ? box.getBoundingClientRect().top : 0;
47      const viewH = box ? box.clientHeight : window.innerHeight;
48      items.forEach((_, i) => {
49        const next = items[i + 1];
50        if (!next) return;
51        const nextTop = next.getBoundingClientRect().top - viewTop;
52        const p = Math.min(1, Math.max(0, (viewH - nextTop) / (viewH - top)));
53        const inner = inners[i];
54        const shade = shades[i];
55        if (inner) inner.style.transform = `scale(${1 - p * 0.1})`;
56        if (shade) shade.style.opacity = String(p * 0.45);
57      });
58    };
59
60    const schedule = () => {
61      if (!frame) frame = requestAnimationFrame(update);
62    };
63
64    update();
65    const target: HTMLElement | Window = scroller?.current ?? window;
66    target.addEventListener("scroll", schedule, { passive: true });
67    window.addEventListener("resize", schedule);
68    return () => {
69      cancelAnimationFrame(frame);
70      target.removeEventListener("scroll", schedule);
71      window.removeEventListener("resize", schedule);
72    };
73  }, [cards, scroller, top]);
74
75  return (
76    <div className="sc" ref={root}>
77      {cards.map((c, i) => (
78        <article
79          key={c.title}
80          className={`sc-card sc-card--${c.tone ?? "ink"}`}
81          style={{ zIndex: i + 1, top, height: cardHeight }}
82        >
83          <div className="sc-card__inner">
84            <div className="sc-card__top">
85              <span>
86                {String(i + 1).padStart(2, "0")} /{" "}
87                {String(cards.length).padStart(2, "0")}
88              </span>
89              {c.year && <span>{c.year}</span>}
90            </div>
91            <div>
92              <h3 className="sc-card__title">{c.title}</h3>
93              {c.hook && <p className="sc-card__hook">{c.hook}</p>}
94            </div>
95            <div className="sc-card__foot">
96              <p className="sc-card__text">{c.text}</p>
97              <div className="sc-card__meta">
98                {c.tags && (
99                  <ul className="sc-card__tags">
100                    {c.tags.map((t) => (
101                      <li key={t}>{t}</li>
102                    ))}
103                  </ul>
104                )}
105                {c.link && (
106                  <a
107                    className="sc-card__link"
108                    href={c.link.href}
109                    target="_blank"
110                    rel="noopener noreferrer"
111                  >
112                    {c.link.label} ↗
113                  </a>
114                )}
115              </div>
116            </div>
117            <span className="sc-card__shade" aria-hidden="true" />
118          </div>
119        </article>
120      ))}
121    </div>
122  );
123}