Wipe Button

A text link with an arrow and a line under it. On hover the line grows upward into a solid bar that covers the label, then keeps going and shrinks back to a line along the top edge. While it's covered, the label drops to the bottom of the box and its last words unscramble back into place. Leaving plays the same wipe downward.

One bar, two edges

The line and the bar are the same element. It is pinned to both edges of the box, and each edge has its own transition. On the way in, the top edge rises first with an ease-in and the bottom edge follows with an ease-out, so the bar is moving fastest at the moment it fills the box. On the way out the order flips. Both halves take 250ms by default (duration).

The label settles as it's uncovered

The label sits at the top of the box at rest and at the bottom on hover, so it always has the line on the far side. It doesn't move during the first half of the wipe. At the midpoint, when the bar covers everything, it starts a 20px slide with a long ease-out. The bar's trailing edge clears it while it is still moving, so the label is seen gliding the last few pixels into place instead of appearing there.

Scramble

The scramble starts at the same midpoint. The first word stays fixed and the rest is replaced with random letters that resolve from left to right over 600ms. Screen readers get the real label the whole time through aria-label, and the visible text is hidden from them. The real label stays in the layout, invisible, to hold the width, and the scrambled letters are drawn on top of it, so the button never changes size while they change. With reduced motion on, the line moves to the top with no wipe and no scramble. Turn it off with scramble={false}.

Sound

With sound on, the button plays three small things, all synthesised with the Web Audio API, so there are no audio files to ship.

  • Sweep. Low, muffled noise through a wide band-pass filter whose centre rises from 280Hz to 1.3kHz over the wipe. It is loudest at the midpoint, when the bar covers the label, so the swell and the fade follow the bar growing and shrinking. Leaving plays it in reverse, falling in pitch and a third quieter. Keeping it under 1.3kHz is what makes it sound like paper sliding and not like air.
  • Knocks. Each time a letter lands, a 30ms sine falls from about 650Hz to 300Hz. The fast drop in pitch is what makes it feel pressed, like a key bottoming out, where a steady high blip only sounds like a beep. Each knock starts 14Hz higher than the last, and a 3ms fade-in takes the click off the front.
  • Settle. The last letter gets a deeper, longer thud that falls from 300Hz to 140Hz over 90ms. It ends the run the way the label ends its slide.

Everything is very quiet (gain 0.07 at most). A quick in-and-out cuts the first sweep short instead of stacking a second one on top. Browsers don't let a hover start audio, so the button stays silent until the page has had a click or a key press. Sound is off by default and off with reduced motion. On this page it follows the speaker toggle in the header.

In a list

The button takes its width from className, so a stack of them lines up as a set of rules.

Usage

<WipeButton label="Get in touch" href="/contact" sound />

Pass href for a link, or leave it out and use onClick for a button.

components/blocks/buttons/wipe-button.tsx
"use client";

import { ArrowRightIcon } from "@phosphor-icons/react";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";

import { cn } from "@/lib/utils";

export type WipeButtonProps = {
  label?: string;
  /** Renders a link when set, a button otherwise. */
  href?: string;
  target?: string;
  rel?: string;
  icon?: React.ReactNode;
  /** Scramble the label back into place after the wipe. */
  scramble?: boolean;
  /** Time for each half of the wipe, in ms. */
  duration?: number;
  /** Play a soft sweep with the wipe and a tick as each letter lands. */
  sound?: boolean;
  className?: string;
  onClick?: React.MouseEventHandler<HTMLElement>;
};

const LETTERS = "abcdefghijklmnopqrstuvwxyz";
const SCRAMBLE_MS = 600;
const TICK_MS = 60;

let context: AudioContext | null = null;
let hiss: AudioBuffer | null = null;

/** The shared audio context, or null while the browser won't let it play. */
function audio() {
  // Hover can't unlock audio, so stay silent until the page has had a click
  // or key press, instead of queueing sounds that burst out later.
  if (typeof window === "undefined" || navigator.userActivation?.hasBeenActive === false) {
    return null;
  }
  context ??= new AudioContext();
  if (context.state !== "running") {
    void context.resume();
    return null;
  }
  return context;
}

/**
 * Low, muffled noise that sweeps up as the bar rises (or down as it falls) and
 * is loudest at the midpoint, when the bar covers the label. Returns a
 * function that fades it out early.
 */
function sweep(up: boolean, seconds: number) {
  const ctx = audio();
  if (!ctx) return null;
  if (!hiss) {
    hiss = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
    const data = hiss.getChannelData(0);
    for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  }
  const now = ctx.currentTime;
  const source = ctx.createBufferSource();
  source.buffer = hiss;
  source.loop = true;
  const filter = ctx.createBiquadFilter();
  filter.type = "bandpass";
  filter.Q.value = 0.7;
  filter.frequency.setValueAtTime(up ? 280 : 1100, now);
  filter.frequency.exponentialRampToValueAtTime(up ? 1300 : 240, now + seconds);
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0, now);
  gain.gain.linearRampToValueAtTime(up ? 0.035 : 0.022, now + seconds / 2);
  gain.gain.linearRampToValueAtTime(0, now + seconds);
  source.connect(filter).connect(gain).connect(ctx.destination);
  source.start(now);
  source.stop(now + seconds);
  return () => {
    const t = ctx.currentTime;
    gain.gain.cancelScheduledValues(t);
    gain.gain.setTargetAtTime(0, t, 0.015);
  };
}

/**
 * A soft knock: a sine that falls from `from` to `to` Hz, like a key bottoming
 * out. The 3ms fade-in takes the click off the front.
 */
function blip(from: number, to: number, seconds: number, volume: number) {
  const ctx = audio();
  if (!ctx) return;
  const now = ctx.currentTime;
  const osc = ctx.createOscillator();
  osc.frequency.setValueAtTime(from, now);
  osc.frequency.exponentialRampToValueAtTime(to, now + seconds);
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0, now);
  gain.gain.linearRampToValueAtTime(volume, now + 0.003);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + seconds);
  osc.connect(gain).connect(ctx.destination);
  osc.start(now);
  osc.stop(now + seconds);
}

/** Keeps the first few characters and randomises the rest. */
function scrambleText(text: string, revealed: number) {
  let out = "";
  for (let i = 0; i < text.length; i++) {
    out += i < revealed ? text[i] : LETTERS[Math.floor(Math.random() * LETTERS.length)];
  }
  return out;
}

export function WipeButton({
  label = "Get in touch",
  href,
  target,
  rel,
  icon,
  scramble = true,
  duration = 250,
  sound = false,
  className,
  onClick,
}: WipeButtonProps) {
  const reduceMotion = useReducedMotion();
  const [active, setActive] = useState(false);
  const [noise, setNoise] = useState<string | null>(null);
  const stopSweep = useRef<(() => void) | null>(null);

  const audible = sound && !reduceMotion;
  const change = (next: boolean) => {
    if (next === active) return;
    setActive(next);
    if (!audible) return;
    // Cut the last sweep short, so a quick in-and-out doesn't stack two.
    stopSweep.current?.();
    stopSweep.current = sweep(next, (duration * 2) / 1000);
  };

  // The first word stays put; everything after it scrambles.
  const split = label.indexOf(" ") + 1;
  const head = label.slice(0, split);
  const tail = label.slice(split);

  useEffect(() => {
    if (!active || !scramble || reduceMotion) return;
    let interval: ReturnType<typeof setInterval> | undefined;
    // Start once the bar fully covers the label, so the swap is never seen.
    const timeout = setTimeout(() => {
      const start = performance.now();
      let landed = 0;
      setNoise(scrambleText(tail, 0));
      interval = setInterval(() => {
        const t = (performance.now() - start) / SCRAMBLE_MS;
        if (t >= 1) {
          clearInterval(interval);
          setNoise(null);
          // A deeper, longer thud for the last letter, so the run resolves.
          if (audible) blip(300, 140, 0.09, 0.07);
          return;
        }
        const revealed = Math.floor(t * tail.length);
        setNoise(scrambleText(tail, revealed));
        // One knock per letter that lands, each a little higher than the last.
        if (audible && revealed > landed) blip(620 + revealed * 14, 300, 0.03, 0.04);
        landed = revealed;
      }, TICK_MS);
    }, duration);
    return () => {
      clearTimeout(timeout);
      clearInterval(interval);
      setNoise(null);
    };
  }, [active, scramble, reduceMotion, audible, tail, duration]);

  const shared = {
    "aria-label": label,
    "data-active": active,
    onClick,
    onPointerEnter: () => change(true),
    onPointerLeave: (e: React.PointerEvent<HTMLElement>) =>
      change(e.currentTarget.matches(":focus-visible")),
    onFocus: (e: React.FocusEvent<HTMLElement>) =>
      change(e.currentTarget.matches(":focus-visible")),
    onBlur: () => change(false),
    className: cn(
      "relative block h-10.5 w-52 cursor-pointer text-left text-base font-medium tracking-tight text-foreground outline-offset-4 outline-ring select-none focus-visible:outline-2",
      className
    ),
    style: {
      "--wipe": duration + "ms",
      "--wipe-in": "cubic-bezier(0.5, 0, 0.75, 0)",
      "--wipe-out": "cubic-bezier(0.25, 1, 0.5, 1)",
    } as React.CSSProperties,
  };

  const content = (
    <>
      {/* Waits at the top of the box until the bar covers it, then eases down
          to the bottom as the bar uncovers it. */}
      <span
        aria-hidden
        className={cn(
          "flex h-5.5 items-center justify-between gap-6 leading-none whitespace-nowrap [transition:translate_calc(var(--wipe)*1.8)_var(--wipe-out)_var(--wipe)] motion-reduce:transition-none",
          active && "translate-y-5"
        )}
      >
        {/* The real label always holds the width; the scramble sits on top
            of it, so the letters never resize the button. */}
        <span className="relative">
          <span className={cn(noise !== null && "invisible")}>{label}</span>
          {noise !== null && (
            <span className="absolute top-0 left-0">
              {head}
              {noise}
            </span>
          )}
        </span>
        {icon ?? <ArrowRightIcon className="size-5.5 shrink-0" />}
      </span>
      {/* One bar, two edges. Entering, the top edge rises first and the
          bottom edge follows; leaving, they go back in the opposite order. */}
      <span
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-x-0 bg-foreground motion-reduce:transition-none",
          active
            ? "top-0 bottom-[calc(100%-1.5px)] [transition:top_var(--wipe)_var(--wipe-in),bottom_var(--wipe)_var(--wipe-out)_var(--wipe)]"
            : "top-[calc(100%-1.5px)] bottom-0 [transition:bottom_var(--wipe)_var(--wipe-in),top_var(--wipe)_var(--wipe-out)_var(--wipe)]"
        )}
      />
    </>
  );

  return href ? (
    <a href={href} target={target} rel={rel} {...shared}>
      {content}
    </a>
  ) : (
    <button type="button" {...shared}>
      {content}
    </button>
  );
}

Search concepts

Search for a command to run...