JoeGet a free quoteFree quote

Joe · Web designer & developer

Websites that make your small business look like the biggest name in town.

You run the business. I design and build a fast, modern site that gets people to call, book and buy. Every effect on this page is something I can build for you.

  • Free, no-obligation quote
  • Fixed prices from £900
  • Reply within one working day

Live 3D · drag to spin, click a site to open itswipe to spin, tap a site to open it

Measured live, on your device

This page loaded in
…
Page size
…less than one photo from your phone
Animation smoothness
…frames a second; 60 is silky

Speed sells: a Deloitte study for Google found that making a shop's site just 0.1 seconds faster meant 8.4% more visitors went on to buy. Read the study

  • Fixed quotes

    You know the full price before any work starts.

  • You own it

    Your domain, your content and your code, with no lock-in.

  • One person, start to finish

    You talk to the person building your site, not a call centre.

The Showroom

Try the effects. Then flip the card to see what's inside.

Each piece below is live and ready to put on your site. Flip any card for a plain-English reason a business would use it, plus a Code Facts label showing how much it adds to your page (hint: barely anything).

Rosa's Bakery

Animated headline

For: any business with a name worth remembering

Why a business uses it

Your name is the first thing people read. A headline that builds itself in a split second makes the page feel premium and keeps eyes on your message instead of the back button.

Code Facts

Serving size: 1 website section

Lines of code
85
Adds to your page
30.5 KB
of which, my own code
0.6 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), SplitText, CSS masks, variable font axes (weight and width).

Lighter than a single photo, so it won't slow your site down.

Animated headline src/demos/kinetic-headline.ts

import { gsap } from 'gsap';
import { SplitText } from 'gsap/SplitText';

gsap.registerPlugin(SplitText);

type Style = 'rise' | 'wave' | 'stretch';

const styles: Record<Style, (chars: Element[]) => gsap.core.Tween> = {
  // Letters slide up from behind a mask, like a printed sign being revealed.
  rise: (chars) =>
    gsap.from(chars, { yPercent: 120, rotate: 8, duration: 0.9, ease: 'expo.out', stagger: 0.03 }),
  // Letters drop in from the middle outwards with a springy bounce.
  wave: (chars) =>
    gsap.from(chars, { y: -80, opacity: 0, duration: 1.2, ease: 'elastic.out(1, 0.45)', stagger: { each: 0.04, from: 'center' } }),
  // One variable font file morphs thin and narrow, then back to the heading's own weight and width.
  stretch: (chars) =>
    gsap.to(chars, { fontWeight: 200, fontStretch: '75%', duration: 0.6, ease: 'power3.inOut', stagger: { each: 0.05, yoyo: true, repeat: 1 } }),
};

// With reduced motion every style becomes a quick, gentle fade.
const fade = (chars: Element[]) => gsap.from(chars, { opacity: 0, duration: 0.3, stagger: 0.01 });

export function mount(root: HTMLElement) {
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const word = root.querySelector<HTMLElement>('[data-kh-word]')!;
  const input = root.querySelector<HTMLInputElement>('[data-kh-input]')!;
  const buttons = [...root.querySelectorAll<HTMLButtonElement>('[data-kh-style]')];
  let current: Style = 'rise';
  let split: SplitText | undefined;
  let tween: gsap.core.Tween | undefined;

  const play = () => {
    tween?.progress(1).kill();
    split?.revert();
    split = SplitText.create(word, { type: 'words,chars', mask: current === 'rise' ? 'chars' : undefined });
    tween = (reduced ? fade : styles[current])(split.chars);
  };

  buttons.forEach((b) =>
    b.addEventListener('click', () => {
      current = b.dataset.khStyle as Style;
      buttons.forEach((o) => o.setAttribute('aria-pressed', String(o === b)));
      play();
    }),
  );

  let timer = 0;
  input.addEventListener('input', () => {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      split?.revert();
      split = undefined;
      word.textContent = input.value.trim() || 'Your Business';
      play();
    }, 350);
  });

  if (!reduced) play();
}

Olive & Ash

Wood-fired dinners, Thursday to Sunday

Move towards the button, then clickTap the button

Magnetic booking button

For: restaurants, salons, clinics, anyone taking bookings

Why a business uses it

The button that makes you money should be impossible to miss. It leans towards the visitor's cursor and answers every click, which makes booking feel easy and a little bit fun.

Code Facts

Serving size: 1 website section

Lines of code
105
Adds to your page
27.7 KB
of which, my own code
0.8 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), quickTo, pointer tracking, CSS blend modes.

Lighter than a single photo, so it won't slow your site down.

Magnetic booking button src/demos/magnetic-button.ts

import { gsap } from 'gsap';

export function mount(root: HTMLElement) {
  const btn = root.querySelector<HTMLButtonElement>('[data-mb-btn]')!;
  const label = root.querySelector<HTMLElement>('[data-mb-label]')!;
  const cursor = root.querySelector<HTMLElement>('[data-mb-cursor]')!;
  const fine = matchMedia('(pointer: fine)').matches;
  // With reduced motion the button stays where it is and the label simply changes.
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

  // quickTo reuses one tween per property, so following the pointer stays cheap.
  const bx = gsap.quickTo(btn, 'x', { duration: 0.5, ease: 'power3.out' });
  const by = gsap.quickTo(btn, 'y', { duration: 0.5, ease: 'power3.out' });
  const lx = gsap.quickTo(label, 'x', { duration: 0.5, ease: 'power3.out' });
  const ly = gsap.quickTo(label, 'y', { duration: 0.5, ease: 'power3.out' });
  const cx = gsap.quickTo(cursor, 'x', { duration: 0.25, ease: 'power3.out' });
  const cy = gsap.quickTo(cursor, 'y', { duration: 0.25, ease: 'power3.out' });

  const radius = 150;
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    if (fine) {
      cursor.style.opacity = '1';
      cx(e.clientX - r.left);
      cy(e.clientY - r.top);
    }
    const b = btn.getBoundingClientRect();
    const dx = e.clientX - (b.left + b.width / 2 - (gsap.getProperty(btn, 'x') as number));
    const dy = e.clientY - (b.top + b.height / 2 - (gsap.getProperty(btn, 'y') as number));
    const near = Math.hypot(dx, dy) < radius;
    cursor.classList.toggle('is-near', near);
    if (reduced) return;
    bx(near ? dx * 0.35 : 0);
    by(near ? dy * 0.35 : 0);
    lx(near ? dx * 0.12 : 0);
    ly(near ? dy * 0.12 : 0);
  });
  root.addEventListener('pointerleave', () => {
    bx(0); by(0); lx(0); ly(0);
    cursor.style.opacity = '0';
  });

  btn.addEventListener('click', (e) => {
    if (reduced) {
      if (btn.dataset.done) return;
      btn.dataset.done = 'true';
      label.textContent = 'Table booked';
      setTimeout(() => {
        label.textContent = 'Book a table';
        delete btn.dataset.done;
      }, 2200);
      return;
    }
    const b = btn.getBoundingClientRect();
    const ripple = document.createElement('span');
    ripple.className = 'mb__ripple';
    ripple.style.left = `${(e.clientX || b.left + b.width / 2) - b.left}px`;
    ripple.style.top = `${(e.clientY || b.top + b.height / 2) - b.top}px`;
    btn.append(ripple);
    gsap.fromTo(ripple, { scale: 0, opacity: 0.5 }, { scale: 6, opacity: 0, duration: 0.8, ease: 'power2.out', onComplete: () => ripple.remove() });

    if (btn.dataset.done) return;
    btn.dataset.done = 'true';
    gsap.timeline()
      .to(label, { yPercent: -120, duration: 0.25, ease: 'power2.in' })
      .add(() => (label.textContent = 'Table booked'))
      .fromTo(label, { yPercent: 120 }, { yPercent: 0, duration: 0.4, ease: 'back.out(2)' })
      .to(label, { yPercent: -120, duration: 0.25, ease: 'power2.in', delay: 1.8 })
      .add(() => {
        label.textContent = 'Book a table';
        delete btn.dataset.done;
      })
      .fromTo(label, { yPercent: 120 }, { yPercent: 0, duration: 0.4, ease: 'back.out(2)' });
  });
}

Corner Café

Today

SoupTomato basil£6

ToastieHam & cheddar£9

CakeLemon drizzle£4

Specials change at noon

Live specials board

For: cafés, bars, bakeries, barbers, gyms with class times

Why a business uses it

Daily specials, today's class times or this week's offers change often. A board that shuffles to the new list like an old railway departure board gets noticed, and you can update it without touching the design.

Code Facts

Serving size: 1 website section

Lines of code
74
Adds to your page
31.2 KB
of which, my own code
0.8 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), ScrambleText, a timed rotation, monospace type for an even board.

Lighter than a single photo, so it won't slow your site down.

Live specials board src/demos/specials-board.ts

import { gsap } from 'gsap';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';

gsap.registerPlugin(ScrambleTextPlugin);

// Each set is one "board" of today's specials; the board scrambles from one set to the next.
const boards = [
  [['Soup', 'Tomato basil', '£6'], ['Toastie', 'Ham & cheddar', '£9'], ['Cake', 'Lemon drizzle', '£4']],
  [['Coffee', 'Oat flat white', '£5'], ['Bagel', 'Smoked salmon', '£11'], ['Pastry', 'Almond croissant', '£4']],
  [['Salad', 'Roast squash', '£10'], ['Pie', 'Steak & ale', '£12'], ['Tart', 'Salted caramel', '£5']],
];

export function mount(root: HTMLElement) {
  const cells = [...root.querySelectorAll<HTMLElement>('[data-sb-cell]')];
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  let index = 0;

  const say = root.querySelector<HTMLElement>('[data-sb-say]');

  const show = (n: number, instant = false) => {
    // Kill any half-finished scramble so quick clicks always land on clean text.
    gsap.killTweensOf(cells);
    boards[n].flat().forEach((text, i) => {
      if (instant) cells[i].textContent = text;
      else gsap.to(cells[i], { duration: 1.1, delay: i * 0.06, scrambleText: { text, chars: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ£0123456789', speed: 0.6 }, ease: 'none' });
    });
  };

  const next = () => {
    index = (index + 1) % boards.length;
    show(index, reduced);
  };

  // The board turns over by itself only while it's properly on screen, and not once the visitor has paused it.
  let timer = 0;
  let visible = false;
  let paused = false;
  const rotate = () => {
    clearInterval(timer);
    if (visible && !reduced && !paused) timer = window.setInterval(next, 4000);
  };

  const pause = root.querySelector<HTMLButtonElement>('[data-sb-pause]');
  pause?.addEventListener('click', () => {
    paused = !paused;
    pause.textContent = paused ? 'Play' : 'Pause';
    root.toggleAttribute('data-paused', paused);
    rotate();
  });

  root.querySelector('[data-sb-next]')?.addEventListener('click', () => {
    next();
    // Screen readers hear the new board once, in plain words, never the scramble.
    if (say) say.textContent = boards[index].map(([dish, name, price]) => `${dish}, ${name.toLowerCase().replace('&', 'and')}, ${price}.`).join(' ');
    // Restart the clock so the visitor has time to read the board they picked (a paused board stays put).
    rotate();
  });

  if (reduced) return;
  new IntersectionObserver(([e]) => {
    const was = visible;
    visible = e.intersectionRatio >= 0.4;
    // Coming into view, the board shuffles once so the effect is seen.
    if (visible && !was && !paused) show(index);
    rotate();
  }, { threshold: 0.4 }).observe(root);
}
BeforeAfter

Before and after slider

For: builders, salons, cleaners, landscapers, designers

Why a business uses it

Nothing sells a transformation like seeing it. Let customers drag between the before and after of your work, whether that is a kitchen, a haircut or a website.

Code Facts

Serving size: 1 website section

Lines of code
69
Adds to your page
0.4 KB
of which, my own code
All of it
Frames per second (60 is silky)
measuring…

Ingredients: a native range input, CSS clip-path, CSS custom properties.

Lighter than a single photo, so it won't slow your site down.

Before and after slider src/demos/before-after.ts

export function mount(root: HTMLElement) {
  const range = root.querySelector<HTMLInputElement>('[data-ba-range]')!;
  // A real range input does the work, so it also works with a keyboard and screen reader.
  const set = () => root.style.setProperty('--pos', `${range.value}%`);
  range.addEventListener('input', set);
  set();

  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  // A short nudge on first view shows people the divider can move.
  const io = new IntersectionObserver(([e]) => {
    if (!e.isIntersecting) return;
    io.disconnect();
    const frames = [50, 30, 70, 50];
    frames.forEach((v, i) =>
      setTimeout(() => {
        root.classList.add('is-nudging');
        range.value = String(v);
        set();
        if (i === frames.length - 1) setTimeout(() => root.classList.remove('is-nudging'), 600);
      }, 400 + i * 550),
    );
  }, { threshold: 0.7 });
  io.observe(root);
}
Example reviews
★★★★★
Best sourdough in town. Get there early!
Priya · Rosa's Bakery
★★★★★
Booked online at midnight, in the chair by 9am.
Hannah · Salon client
★★★★★
Burst pipe on a Sunday. Here within the hour.
Mark · PipePros customer
★★★★★
Flowers for Mum arrived the same afternoon.
Ellie · Bloom customer
★★★★★
Best coaches I have had. First class was free!
Dan · Summit member
★★★★★
Garden transformed, and they left it spotless.
Sue · Landscaping customer

Moving review wall

For: any business with happy customers

Why a business uses it

Customers check reviews before they pick a café, a plumber or a hairdresser. A slow, endless strip of them shows people love you at a glance, without taking over the page.

Code Facts

Serving size: 1 website section

Lines of code
41
Adds to your page
27.4 KB
of which, my own code
0.5 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), an endless timeline loop, timeScale easing on hover, CSS edge fades.

Lighter than a single photo, so it won't slow your site down.

Moving review wall src/demos/review-marquee.ts

import { gsap } from 'gsap';

export function mount(root: HTMLElement) {
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const rows = [...root.querySelectorAll<HTMLElement>('[data-rm-row]')];

  const loops = rows.map((row, i) => {
    // Duplicate the reviews so the strip can loop with no visible seam.
    row.append(...[...row.children].map((c) => {
      const copy = c.cloneNode(true) as HTMLElement;
      copy.setAttribute('aria-hidden', 'true');
      return copy;
    }));
    const dir = i % 2 ? 1 : -1;
    return gsap.fromTo(row, { xPercent: dir < 0 ? 0 : -50 }, { xPercent: dir < 0 ? -50 : 0, duration: 28 + i * 6, ease: 'none', repeat: -1, paused: reduced });
  });

  // Slow right down on hover so a review can be read.
  const speed = (s: number) => loops.forEach((l) => gsap.to(l, { timeScale: s, duration: 0.6, ease: 'power2.out' }));
  root.addEventListener('pointerenter', () => speed(0.15));
  root.addEventListener('pointerleave', () => speed(1));

  // Keyboard and touch visitors can stop the wall outright (with reduced motion it never moves, and the button is hidden).
  const pause = root.querySelector<HTMLButtonElement>('[data-rm-pause]');
  if (pause && !reduced) {
    pause.addEventListener('click', () => {
      const p = !loops[0].paused();
      loops.forEach((l) => l.paused(p));
      pause.textContent = p ? 'Play' : 'Pause';
    });
  }
}

Summit Fitness

Example data
Members
1,284
Classes a week
46
Google rating
4.9
JanOct
Members per month

Counters and live chart

For: gyms, trades, agencies, anyone with results to show

Why a business uses it

Numbers build trust faster than words. Counting up your customers or reviews as the visitor scrolls turns a dry fact into a moment people notice and remember.

Code Facts

Serving size: 1 website section

Lines of code
72
Adds to your page
29.2 KB
of which, my own code
0.6 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), a timeline, DrawSVG, IntersectionObserver.

Lighter than a single photo, so it won't slow your site down.

Counters and live chart src/demos/stat-counters.ts

import { gsap } from 'gsap';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';

gsap.registerPlugin(DrawSVGPlugin);

export function mount(root: HTMLElement) {
  const numbers = [...root.querySelectorAll<HTMLElement>('[data-count]')];
  const line = root.querySelector<SVGPathElement>('[data-sc-line]')!;
  const area = root.querySelector<SVGPathElement>('[data-sc-area]')!;
  const dot = root.querySelector<SVGCircleElement>('[data-sc-dot]')!;
  const replay = root.querySelector<HTMLButtonElement>('[data-sc-replay]')!;

  const tl = gsap.timeline({ paused: true });
  numbers.forEach((el, i) => {
    const end = Number(el.dataset.count);
    const decimals = Number(el.dataset.decimals ?? 0);
    const counter = { v: 0 };
    tl.to(
      counter,
      {
        v: end,
        duration: 1.6,
        ease: 'power2.out',
        onUpdate: () => {
          el.textContent = counter.v.toLocaleString('en-GB', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
        },
      },
      i * 0.15,
    );
  });
  tl.fromTo(line, { drawSVG: '0%' }, { drawSVG: '100%', duration: 1.6, ease: 'power2.inOut' }, 0.2)
    .fromTo(area, { opacity: 0 }, { opacity: 1, duration: 0.8 }, 1)
    .fromTo(dot, { scale: 0, transformOrigin: '50% 50%' }, { scale: 1, duration: 0.5, ease: 'back.out(3)' }, 1.7);

  replay.addEventListener('click', () => tl.restart());

  // With reduced motion, show the finished chart straight away (Replay still animates it on request).
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    tl.progress(1);
    return;
  }
  // Play once the card is properly in view, so the visitor sees it happen.
  const io = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        tl.restart();
        io.disconnect();
      }
    },
    { threshold: 0.6 },
  );
  io.observe(root);
}

Bloom

Tap a bouquet

Smooth page transitions

For: shops, menus, portfolios, galleries

Why a business uses it

Instead of a jarring blank page, the photo you tap glides into the product page. Visitors stay oriented, browse more, and the whole site feels like a polished app.

Code Facts

Serving size: 1 website section

Lines of code
114
Adds to your page
36.4 KB
of which, my own code
0.9 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), Flip, the same idea as the browser View Transitions API.

Lighter than a single photo, so it won't slow your site down.

Smooth page transitions src/demos/page-transition.ts

import { gsap } from 'gsap';
import { Flip } from 'gsap/Flip';

gsap.registerPlugin(Flip);

export function mount(root: HTMLElement) {
  const tiles = [...root.querySelectorAll<HTMLButtonElement>('[data-pt-tile]')];
  const grid = root.querySelector<HTMLElement>('.pt__grid')!;
  const detail = root.querySelector<HTMLElement>('[data-pt-detail]')!;
  const slot = root.querySelector<HTMLElement>('[data-pt-slot]')!;
  const back = root.querySelector<HTMLButtonElement>('[data-pt-back]')!;
  const title = root.querySelector<HTMLElement>('[data-pt-title]')!;
  const price = root.querySelector<HTMLElement>('[data-pt-price]')!;
  const add = root.querySelector<HTMLButtonElement>('.pt__buy');
  const fades = detail.querySelectorAll<HTMLElement>('[data-pt-fade]');
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  let open: HTMLButtonElement | null = null;
  let closing = false;
  let home: Element | null = null;
  let addTimer = 0;

  // Flip records where the photo is, moves it in the page, then animates the difference.
  const move = (art: HTMLElement, into: Element, done?: () => void) => {
    const state = Flip.getState(art);
    into.append(art);
    Flip.from(state, {
      duration: 0.7,
      ease: 'power3.inOut',
      absolute: true,
      zIndex: 5,
      onComplete: () => {
        gsap.set(art, { clearProps: 'all' });
        done?.();
      },
    });
  };

  const resetAdd = () => {
    clearTimeout(addTimer);
    if (!add?.dataset.label) return;
    add.textContent = add.dataset.label;
    add.style.minWidth = '';
  };

  tiles.forEach((tile) =>
    tile.addEventListener('click', () => {
      if (open || closing) return;
      open = tile;
      const art = tile.querySelector<HTMLElement>('[data-pt-art]')!;
      home = art.parentElement;
      title.textContent = tile.dataset.name!;
      price.textContent = tile.dataset.price!;
      resetAdd();
      detail.hidden = false;
      // The shop's tiles are hidden behind the product page, so keyboard focus must not wander onto them.
      grid.inert = true;
      if (reduced) {
        // Reduced motion: the product page simply fades in over the shop.
        slot.append(art);
        gsap.fromTo(detail, { opacity: 0 }, { opacity: 1, duration: 0.3 });
      } else {
        move(art, slot);
        gsap.fromTo(fades, { y: 16, opacity: 0 }, { y: 0, opacity: 1, stagger: 0.06, delay: 0.35, duration: 0.4 });
        gsap.to(tiles, { opacity: 0, duration: 0.25 });
      }
      back.focus({ preventScroll: true });
    }),
  );

  back.addEventListener('click', () => {
    if (!open || !home || closing) return;
    const art = slot.querySelector<HTMLElement>('[data-pt-art]')!;
    const tile = open;
    closing = true;
    resetAdd();
    const finish = () => {
      detail.hidden = true;
      gsap.set(detail, { clearProps: 'opacity,backgroundColor,pointerEvents' });
      open = null;
      closing = false;
    };
    grid.inert = false;
    tile.focus({ preventScroll: true });
    if (reduced) {
      home.append(art);
      gsap.to(detail, { opacity: 0, duration: 0.3, onComplete: finish });
      return;
    }
    // Let the shop show through while the photo glides back into its own tile.
    gsap.set(detail, { backgroundColor: 'transparent', pointerEvents: 'none' });
    // Back can come while the opening fades are still running; stop them so none finishes later.
    gsap.killTweensOf(fades, 'y,opacity');
    gsap.to(fades, { opacity: 0, duration: 0.2 });
    gsap.set(tile, { opacity: 1, overwrite: true });
    gsap.to(tiles.filter((t) => t !== tile), { opacity: 1, duration: 0.35, delay: 0.15, overwrite: true });
    move(art, home, finish);
  });

  // A small confirmation, like a real shop gives.
  add?.addEventListener('click', () => {
    add.dataset.label ??= add.textContent ?? '';
    // Hold the button's width so the shorter label doesn't make it shrink.
    add.style.minWidth = `${add.offsetWidth}px`;
    add.textContent = 'Added ✓';
    if (!reduced) gsap.fromTo(add, { scale: 0.92 }, { scale: 1, duration: 0.5, ease: 'back.out(3)' });
    clearTimeout(addTimer);
    addTimer = window.setTimeout(resetAdd, 1500);
  });
}
Shop
Order placedFresh Market · order 1042

Animated map and tracking

For: delivery, trades, mobile services

Why a business uses it

Showing your service area or an order on its way builds confidence. Customers can see you cover their street and know exactly what happens after they order.

Code Facts

Serving size: 1 website section

Lines of code
48
Adds to your page
42.8 KB
of which, my own code
0.6 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), DrawSVG, MotionPath, MorphSVG, hand-drawn SVG map.

Lighter than a single photo, so it won't slow your site down.

Animated map and tracking src/demos/delivery-map.ts

import { gsap } from 'gsap';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';

gsap.registerPlugin(DrawSVGPlugin, MotionPathPlugin, MorphSVGPlugin);

export function mount(root: HTMLElement) {
  const route = root.querySelector<SVGPathElement>('[data-dm-route]')!;
  const van = root.querySelector<SVGGElement>('[data-dm-van]')!;
  const pin = root.querySelector<SVGGElement>('[data-dm-pin]')!;
  const icon = root.querySelector<SVGPathElement>('[data-dm-icon]')!;
  const status = root.querySelector<HTMLElement>('[data-dm-status]')!;
  const button = root.querySelector<HTMLButtonElement>('[data-dm-go]')!;
  const box = icon.getAttribute('d')!;
  const tick = root.querySelector<SVGPathElement>('[data-dm-tick]')!.getAttribute('d')!;

  const tl = gsap.timeline({ paused: true })
    .add(() => (status.textContent = 'Out for delivery'))
    .set(icon, { morphSVG: box })
    .fromTo(route, { drawSVG: '0%' }, { drawSVG: '100%', duration: 1.2, ease: 'power1.inOut' })
    .fromTo(pin, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.5, ease: 'bounce.out' }, 0.9)
    .to(van, { motionPath: { path: route, align: route, alignOrigin: [0.5, 0.5], autoRotate: true }, duration: 2.4, ease: 'power1.inOut' }, 1.2)
    .add(() => (status.textContent = 'Delivered'))
    .to(icon, { morphSVG: tick, duration: 0.5, ease: 'back.out(2)' });

  button.addEventListener('click', () => tl.restart());
  if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        tl.restart();
        io.disconnect();
      }
    }, { threshold: 0.6 });
    io.observe(root);
  } else {
    // Reduced motion: show the delivered state straight away.
    tl.progress(1);
  }
}

Move over the card · click to turn itTap the card to turn it over

3D tilt card

For: gift cards, memberships, products, offers

Why a business uses it

Turning a flat offer into an object people want to touch makes it feel valuable. It is perfect for gift cards, loyalty schemes or a featured product.

Code Facts

Serving size: 1 website section

Lines of code
77
Adds to your page
27.6 KB
of which, my own code
0.6 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), quickTo, CSS 3D transforms, blend-mode light glare.

Lighter than a single photo, so it won't slow your site down.

3D tilt card src/demos/tilt-card.ts

import { gsap } from 'gsap';

export function mount(root: HTMLElement) {
  const card = root.querySelector<HTMLElement>('[data-tc-card]')!;
  const glare = root.querySelector<HTMLElement>('[data-tc-glare]')!;
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const rx = gsap.quickTo(card, 'rotationX', { duration: 0.6, ease: 'power3.out' });
  const ry = gsap.quickTo(card, 'rotationY', { duration: 0.6, ease: 'power3.out' });
  gsap.set(card, { transformPerspective: 900 });

  // One rotation drives both the tilt and the turn, so neither cancels the other.
  let turned = false;
  let lx = 0;
  let ly = 0;
  // Turned over, the card's up and down are mirrored, so the vertical tilt flips with it.
  const tilt = () => {
    ry((turned ? 180 : 0) + lx * 30);
    rx((turned ? 1 : -1) * ly * 24);
  };

  // Tilt toward the pointer and slide a highlight across, like light on a real card.
  root.addEventListener('pointermove', (e) => {
    if (e.pointerType === 'touch' || reduced) return;
    const r = root.getBoundingClientRect();
    lx = (e.clientX - r.left) / r.width - 0.5;
    ly = (e.clientY - r.top) / r.height - 0.5;
    tilt();
    glare.style.setProperty('--gx', `${(lx + 0.5) * 100}%`);
    glare.style.setProperty('--gy', `${(ly + 0.5) * 100}%`);
    glare.style.opacity = '1';
  });
  root.addEventListener('pointerleave', () => {
    if (reduced) return;
    lx = 0;
    ly = 0;
    tilt();
    glare.style.opacity = '0';
  });

  // A click or tap spins the card to show its back.
  card.addEventListener('click', () => {
    turned = !turned;
    if (!reduced) return tilt();
    // Reduced motion: a quick fade swaps the faces instead of a spin.
    gsap.timeline()
      .to(card, { opacity: 0, duration: 0.15 })
      .set(card, { rotationY: turned ? 180 : 0 })
      .to(card, { opacity: 1, duration: 0.15 });
  });

  if (reduced) return;
  // The card swings into place once it's properly on screen, so the visitor sees it arrive.
  // The swing itself goes through the tilt setters, so a hover or tap part-way through just takes over.
  gsap.set(card, { rotationY: -35, rotationX: 15, y: 30, opacity: 0 });
  const io = new IntersectionObserver(
    ([entry]) => {
      if (!entry.isIntersecting) return;
      io.disconnect();
      gsap.to(card, { y: 0, opacity: 1, duration: 1, ease: 'power3.out' });
      tilt();
    },
    { threshold: 0.5 },
  );
  io.observe(root);
}

Dinneratthefarm.

Scroll photo reveal

For: restaurants, venues, photographers, estate agents

Why a business uses it

Your photos are your best salesperson. Revealing them as the visitor scrolls turns a plain gallery into a moment, so the food, the room or the view gets the attention it deserves.

Code Facts

Serving size: 1 website section

Lines of code
57
Adds to your page
44.8 KB
of which, my own code
0.7 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), a timeline, CSS clip-path masks, ScrollTrigger parallax.

Lighter than a single photo, so it won't slow your site down.

Scroll photo reveal src/demos/photo-reveal.ts

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

export function mount(root: HTMLElement) {
  const replay = root.querySelector<HTMLButtonElement>('[data-pr-replay]');
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    // The photos simply show; there's nothing to replay.
    replay?.setAttribute('hidden', '');
    return;
  }
  const frames = root.querySelectorAll<HTMLElement>('[data-pr-frame]');
  const images = root.querySelectorAll<HTMLElement>('[data-pr-img]');
  const words = root.querySelectorAll<HTMLElement>('[data-pr-word]');

  // The reveal: photos unmask from the bottom while the image inside settles in.
  const reveal = gsap.timeline({ paused: true })
    .fromTo(frames, { clipPath: 'inset(100% 0% 0% 0% round 12px)' }, { clipPath: 'inset(0% 0% 0% 0% round 12px)', duration: 1.1, stagger: 0.18, ease: 'expo.inOut' }, 0)
    .fromTo(images, { scale: 1.4 }, { scale: 1.08, duration: 1.6, stagger: 0.18, ease: 'expo.out' }, 0.1)
    .fromTo(words, { yPercent: 110 }, { yPercent: 0, duration: 0.8, stagger: 0.07, ease: 'power3.out' }, 0.3);
  reveal.progress(0);

  // It plays the moment the card is properly on screen.
  const io = new IntersectionObserver(([e]) => {
    if (!e.isIntersecting) return;
    reveal.restart();
    io.disconnect();
  }, { threshold: 0.55 });
  io.observe(root);
  replay?.addEventListener('click', () => reveal.restart());

  // While you scroll, the photos drift inside their frames for a sense of depth.
  images.forEach((img, i) =>
    gsap.fromTo(img, { yPercent: -6 - i * 2 }, {
      yPercent: 6 + i * 2,
      ease: 'none',
      scrollTrigger: { trigger: root, start: 'top bottom', end: 'bottom top', scrub: true },
    }),
  );

  // Cards above this one can change height after it loads, so re-measure when the page does.
  // Never mid-scroll though: a refresh during a smooth scroll to a link sends it to the wrong place.
  let timer = 0;
  const refresh = () => {
    clearTimeout(timer);
    timer = window.setTimeout(() => (ScrollTrigger.isScrolling() ? refresh() : ScrollTrigger.refresh()), 250);
  };
  let lastHeight = document.body.offsetHeight;
  new ResizeObserver(() => {
    const h = document.body.offsetHeight;
    if (h === lastHeight) return;
    lastHeight = h;
    refresh();
  }).observe(document.body);
}

Move your cursor over the dots.Drag your finger across the dots.

The closer you get, the brighter they glow

Interactive dot background

For: any business that wants a memorable first screen

Why a business uses it

A background that responds to the visitor turns a plain section into something people play with. It invites them to move around the page and makes your brand feel modern and alive.

Code Facts

Serving size: 1 website section

Lines of code
105
Adds to your page
1.1 KB
of which, my own code
All of it
Frames per second (60 is silky)
measuring…

Ingredients: HTML canvas, distance-based colour blending, smoothed pointer tracking.

Lighter than a single photo, so it won't slow your site down.

Interactive dot background src/demos/dot-grid.ts

// A field of dots that light up and swell the closer your cursor gets.
export function mount(root: HTMLElement) {
  const canvas = root.querySelector<HTMLCanvasElement>('[data-dg-canvas]')!;
  const copy = root.querySelector<HTMLElement>('.dg__copy');
  const ctx = canvas.getContext('2d')!;
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const gap = 22;
  const radius = 170;
  let w = 0;
  let h = 0;
  let dpr = 1;
  let clear = 0; // How far down the card the ghost can roam before it reaches the words.
  let dots: { x: number; y: number }[] = [];
  const colours = { base: '#d9dce3', near: [41, 69, 255], hot: [255, 92, 168] };

  // Dots take their colours from the page theme, so dark mode gets the lighter blue.
  const readTheme = () => {
    const css = getComputedStyle(root);
    colours.base = css.getPropertyValue('--line').trim() || colours.base;
    const hex = css.getPropertyValue('--accent').trim().match(/^#([0-9a-f]{6})/i)?.[1];
    if (hex) colours.near = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
  };

  const resize = () => {
    dpr = Math.min(devicePixelRatio, 2);
    w = canvas.clientWidth;
    h = canvas.clientHeight;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    clear = Math.max(h * 0.5, copy?.offsetTop ?? h * 0.75);
    dots = [];
    const cols = Math.floor(w / gap);
    const rows = Math.floor(h / gap);
    const ox = (w - (cols - 1) * gap) / 2;
    const oy = (h - (rows - 1) * gap) / 2;
    for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) dots.push({ x: ox + c * gap, y: oy + r * gap });
    // With reduced motion nothing wanders, so the glow rests up and to the right to show what the card does.
    if (reduced && !inside) {
      target.x = pos.x = w * 0.72;
      target.y = pos.y = clear * 0.4;
    }
  };

  // The pointer is smoothed so the glow glides rather than jumps.
  const target = { x: -999, y: -999 };
  const pos = { x: -999, y: -999 };
  let inside = false;
  root.addEventListener('pointermove', (e) => {
    const r = canvas.getBoundingClientRect();
    target.x = e.clientX - r.left;
    target.y = e.clientY - r.top;
    inside = true;
  });
  root.addEventListener('pointerleave', () => (inside = false));

  const mix = (a: number[], b: number[], t: number) => a.map((v, i) => Math.round(v + (b[i] - v) * t));

  let running = false;
  let raf = 0;
  const frame = (now: number) => {
    // With no cursor on the card, a slow "ghost" wanders so the effect is visible at rest and on phones.
    if (!inside && !reduced) {
      const t = now / 1000;
      // It keeps to the space above the words, so they stay easy to read.
      target.x = w / 2 + Math.cos(t * 0.7) * w * 0.35;
      target.y = clear * (0.42 + Math.sin(t * 1.1) * 0.24);
    }
    pos.x += (target.x - pos.x) * 0.15;
    pos.y += (target.y - pos.y) * 0.15;

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    for (const d of dots) {
      const dx = d.x - pos.x;
      const dy = d.y - pos.y;
      const dist = Math.hypot(dx, dy);
      const t = Math.max(0, 1 - dist / radius);
      const e = t * t * (3 - 2 * t);
      // Nearby dots are nudged away from the cursor, like iron filings near a magnet.
      const push = e * 6;
      const x = d.x + (dist ? (dx / dist) * push : 0);
      const y = d.y + (dist ? (dy / dist) * push : 0);
      ctx.beginPath();
      ctx.arc(x, y, 1.6 + e * 3.4, 0, Math.PI * 2);
      if (e < 0.02) ctx.fillStyle = colours.base;
      else {
        const [r, g, b] = mix(colours.near, colours.hot, Math.max(0, (e - 0.55) / 0.45));
        ctx.fillStyle = `rgba(${r},${g},${b},${0.25 + e * 0.75})`;
      }
      ctx.fill();
    }
    if (running) raf = requestAnimationFrame(frame);
  };

  new ResizeObserver(() => {
    resize();
    if (!running) requestAnimationFrame(frame);
  }).observe(canvas);
  readTheme();
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', readTheme);
  new MutationObserver(readTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

  // Only animate while the card is on screen.
  new IntersectionObserver(([entry]) => {
    running = entry.isIntersecting;
    cancelAnimationFrame(raf);
    if (running) raf = requestAnimationFrame(frame);
  }).observe(root);
}

Seen one you like?

Every piece works on phones, calms down for people who switch animations off, and loads only when you scroll to it.

Ask for it on your site

How it works · keep scrolling

From first call to a site that brings in customers.

  1. We talk

    A free 30-⁠minute call about your business, your customers and what the site needs to do. You get a fixed quote, with no obligation.

    You: 30 minutes on the phone

  2. I design

    I sketch the pages, then design them with your colours, photos and words. You see it before anything is built.

    You: say what you like and what to change

  3. I build it

    The design becomes a real, fast website that works on every phone, with the motion that makes it feel alive.

    You: keep running your business

  4. You launch

    Your site goes live on your own web address (like rosasbakery.co.uk), set up with Google so customers can find you.

    You: start taking calls and bookings

Step one is a free 30-minute call. No obligation, no jargon.

Book your free call
How this section is built

A scroll-linked timeline: the section pins in place and your scrolling drives the story. Businesses use it to explain a process, show off a product or walk visitors through a menu without a wall of text.

Code Facts

Serving size: 1 website section

Lines of code
159
Adds to your page
45.1 KB
of which, my own code
0.8 KB
Frames per second (60 is silky)
measuring…

Ingredients: GSAP (animation engine), ScrollTrigger, pinning and scrub, matchMedia for phones and reduced motion.

Lighter than a single photo, so it won't slow your site down.

Packages

Clear prices. No surprises.

Pick the closest fit. After a free 30-minute call I'll send you a fixed quote in writing, and that's the price you pay: no hourly billing, no extras added later.

Starter

from £900

A sharp online front door for a new or simple business.

  • 1 to 3 pages
  • Looks great on every phone
  • Contact form straight to your inbox
  • Set up so Google can find you
  • Live in about 2 weeks
Choose Starter

Premium

from £4,500

A signature site that makes you the obvious choice.

Everything in Business, plus:

  • A showpiece animation or 3D welcome
  • Online booking or a small shop
  • Help writing your pages
  • Loads fast: 90+ on Google's speed test
  • First in line for help after launch
Choose Premium

Care plan £75 / month

Optional, for any package. I look after the site so you never have to think about it.

  • Hosting set up and watched
  • Updates and backups
  • Small text and photo changes
  • Monthly speed check

Questions

Fair questions, straight answers.

Anything else on your mind? Ask me and I'll answer in plain English.

You're new. Why should I trust you with my website?

Fair question. Everything on this page, from the 3D at the top to every demo in the Showroom, is my own work, built from scratch, so you can judge the quality before you spend a penny. You approve the design before anything is built, and you always deal with me directly.

What do I need to give you?

Not much. Your logo if you have one, some photos, and the basics: what you do, where you are, opening hours and prices. No good photos, or hate writing? Say so on our call and we'll sort it together.

How long will it take?

Starter sites are usually live in about 2 weeks. Bigger sites take longer, and your quote includes a launch date. Most of the wait is on photos and feedback, so the faster you reply, the faster you launch.

How do I pay?

You get the full price and how to pay it in writing with your quote, before any work starts. That's the price you pay. Nothing gets added later without you agreeing to it first.

Do I own the website?

Yes. Your web address, your words, your photos and the code are yours, set up in your name. If you ever want to move on, you take everything with you.

Will people find me on Google?

Every package is set up so Google can find you, with proper page titles, descriptions and fast loading. Business and Premium also set up your Google Maps listing and local search, which is where most customers find a local business.

I already have a website. Can you redesign it?

Yes. Send me the link and I'll tell you what I'd keep, what I'd change and what it would cost.

What happens after it goes live?

You can add the care plan (£75 a month) and I'll look after hosting, updates, backups and small changes for you. Or skip it, and get in touch whenever you need something changed.

Joe, smiling, in a navy suit and white shirt

About

Hi, I'm Joe.

I design and build websites for small businesses: cafés, trades, salons and shops. I do every part myself, from the first chat to the day it goes live, so you always know who you're talking to.

I'm taking on my first clients now, so I won't show you a wall of logos. Judge me on this page instead. The 3D at the top, every demo in the Showroom and the bakery being built above are all my own work, made from scratch. The businesses are made up. Your site will be the real thing.

What you get: a fixed price, plain English, a reply within one working day, and a site that's yours to keep.

Free quote

Tell me about your business.

Answer a few quick questions and I'll reply within one working day with ideas and a fixed price. It's free, there's no obligation, and if I'm not the right fit I'll tell you.

Prefer to talk?

07548 075622

Call or text anytime. If I miss you, I'll email and call you back as soon as I'm free.

This opens your email app with your answers filled in, ready to send.