Morph Link
Rua das Flores 28
1200-195 Lisboa
Sanjō-dōri 112, Nakagyō
604-8005 Kyoto
A small link for a place, such as a studio on a contact page: a round badge with a short code, and a name beside it. On hover the name fades out and a pill grows out of the badge to the right, carrying the action ("Get directions"). The two shapes stay joined by a thin neck, like two drops of liquid. On the way out the pill overshoots, the neck tears, and it pulls back together as the pill settles. The shape is rendered with a WebGL shader, so the join behaves like liquid rather than a drawn curve.
A smooth union in a shader
The shape is drawn by a small WebGL fragment shader. For every pixel it measures the signed distance to two shapes, the badge (a circle) and the pill (a capsule), and merges them with a polynomial smooth minimum. Wherever the two edges come within a few pixels of each other, the smooth minimum pulls them together. That one rule gives all of the liquid behaviour:
- At rest the pill sits 3px clear of the badge, close enough to be bridged, so the two are joined by a pinched waist.
- As the edges part, the waist thins. Past about 6px it lets go, and both ends pull into points before rounding off, the way a drop breaks.
- When the pill comes back the points reach for each other and the neck reforms.
The blend width (SMOOTH) is the only setting for the neck. Near the start,
when the pill still sits inside the badge, the two distances are equal all the
way round and a full blend would swell the outline, so the blend fades in as
the pill leaves. The edge is antialiased in the shader from the distance
itself, so it stays crisp at any pixel ratio.
One value drives everything
A single value runs from 0 to 1. The pill's offset, its width, the blend, the title fading out, and the label fading in are all read from it. Each hover change springs it from wherever it is, with its current velocity, so leaving halfway through reverses smoothly.
The tear comes from the spring itself. The way in is underdamped and overshoots by about a fifth, which carries the pill past the point where the neck lets go; then it eases back and the neck reforms. Nothing is scripted as a separate step, so it still works when the hover is interrupted.
The way out is critically damped. A bouncy exit dips below rest and rebounds, which pushes the pill a couple of pixels back out of the badge after it has merged, and for a frame the badge flashes as a short flat capsule.
Only while it moves
Browsers allow only a handful of WebGL contexts at once, so a page full of these links can't each keep a canvas. At rest the badge is a plain SVG circle. The canvas is created on hover, draws only when the spring changes, and is released once the pill is back inside the badge. Without WebGL, or with reduced motion, the link falls back to an SVG pill and jumps between its two states.
The text never goes through the shader. The badge code, title, and label are real DOM text on top of the canvas, so they stay sharp, selectable, and readable by screen readers.
Details
- The label sits inside a clip that matches the pill, so the pill's growth uncovers it rather than it simply fading in.
- The link reserves the pill's full width, so moving onto the pill doesn't drop the hover.
- Keyboard focus plays the same animation.
- The fill defaults to the
primarytoken, so it is dark in the light theme and light in the dark theme. The shader reads the computed colour each time it starts, so a theme switch is picked up on the next hover. Passcolorfor an accent.
Usage
<MorphLink
badge="LIS"
title="Lisbon Studio"
label="Get directions"
href="https://maps.google.com/?q=Rua+das+Flores+28+Lisboa"
/>
"use client";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
type MotionValue,
} from "motion/react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export type MorphLinkProps = {
/** Short code printed in the round badge. */
badge?: string;
/** Shown beside the badge at rest. */
title?: string;
/** Printed on the pill that grows out of the badge on hover. */
label?: string;
href?: string;
target?: string;
rel?: string;
/** Fill of the badge and pill. Defaults to the primary token. */
color?: string;
className?: string;
};
// Badge radius, and the gap between the badge and the pill once it settles.
const R = 14;
const GAP = 3;
const REST = 2 * R + GAP;
// How far the smooth union reaches, in px. Two edges closer than about half
// of this are bridged, so the settled 3px gap holds a neck, and the spring's
// overshoot (about 7px further out) tears it before the pill settles back.
const SMOOTH = 13;
// Room around the shape for the bridge and the overshoot.
const BLEED = 6;
const OVERSHOOT = 1.35;
// In: underdamped, so the overshoot tears the neck. Out: critically damped.
// A bounce on the way out would dip below rest and rebound, popping the pill
// back out of the badge for a frame after it had merged.
const ENTER = { type: "spring", stiffness: 320, damping: 15, mass: 1 } as const;
const EXIT = { type: "spring", stiffness: 380, damping: 39, mass: 1 } as const;
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
/** Pill offset and width for a given spring progress. */
const offsetAt = (p: number) => Math.max(0, p * REST);
const widthAt = (p: number, full: number) => Math.max(2 * R, 2 * R + p * (full - 2 * R));
/**
* Blend width for a given offset. Where the badge and pill coincide their
* distances are equal everywhere, and a full blend would swell the whole
* outline, so it fades in as the pill leaves the badge.
*/
const smoothAt = (d: number) => SMOOTH * clamp((d - 0.6 * R) / R);
const VERTEX = `
attribute vec2 a;
void main() { gl_Position = vec4(a, 0.0, 1.0); }
`;
// Signed distances to the badge (a circle) and the pill (a capsule), merged
// with a polynomial smooth minimum: the edges flow into each other wherever
// they come within u_k of one another.
const FRAGMENT = `
precision highp float;
uniform float u_height;
uniform float u_scale;
uniform float u_bleed;
uniform float u_r;
uniform float u_d;
uniform float u_w;
uniform float u_k;
uniform vec4 u_color;
float capsule(vec2 p, vec2 a, vec2 b, float r) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-4), 0.0, 1.0);
return length(pa - ba * h) - r;
}
float smin(float a, float b, float k) {
k = max(k, 1e-3);
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, u_height - gl_FragCoord.y) / u_scale - u_bleed;
float badge = length(p - vec2(u_r)) - u_r;
float pill = capsule(p, vec2(u_d + u_r, u_r), vec2(u_d + u_w - u_r, u_r), u_r);
float s = smin(badge, pill, u_k);
float alpha = clamp(0.5 - s * u_scale, 0.0, 1.0);
gl_FragColor = vec4(u_color.rgb, 1.0) * u_color.a * alpha;
}
`;
/** Resolves any CSS colour, oklch included, to 0–1 RGBA. */
function toRGBA(css: string): [number, number, number, number] {
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!ctx) return [0, 0, 0, 1];
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255, a / 255];
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
}
type Liquid = { draw: (d: number, w: number, k: number) => void; dispose: () => void };
/** Sets up the shader on a canvas. Null when WebGL isn't available. */
function createLiquid(canvas: HTMLCanvasElement, color: string, scale: number): Liquid | null {
const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: true, antialias: false });
if (!gl) return null;
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
const program = gl.createProgram();
if (!vs || !fs || !program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
gl.useProgram(program);
// One triangle that covers the whole viewport.
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const a = gl.getAttribLocation(program, "a");
gl.enableVertexAttribArray(a);
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
const u = (name: string) => gl.getUniformLocation(program, name);
gl.uniform1f(u("u_height"), canvas.height);
gl.uniform1f(u("u_scale"), scale);
gl.uniform1f(u("u_bleed"), BLEED);
gl.uniform1f(u("u_r"), R);
gl.uniform4fv(u("u_color"), toRGBA(color));
const ud = u("u_d");
const uw = u("u_w");
const uk = u("u_k");
gl.viewport(0, 0, canvas.width, canvas.height);
return {
draw(d, w, k) {
gl.uniform1f(ud, d);
gl.uniform1f(uw, w);
gl.uniform1f(uk, k);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
dispose() {
gl.getExtension("WEBGL_lose_context")?.loseContext();
},
};
}
/** A pill with its left cap at x, for the SVG fallback. */
function pill(x: number, w: number) {
const right = x + w;
return (
`M${x + R} 0H${right - R}` +
`A${R} ${R} 0 0 1 ${right - R} ${2 * R}` +
`H${x + R}A${R} ${R} 0 0 1 ${x + R} 0Z`
);
}
/**
* Draws the badge and pill with the shader while the link is hovered or
* settling. The context is created on hover and released once the pill is
* back inside the badge, so a page full of links holds at most a few.
*/
function useLiquid({
live,
canvasRef,
svgRef,
progress,
width,
size,
onSettle,
onFail,
}: {
live: boolean;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
svgRef: React.RefObject<SVGSVGElement | null>;
progress: MotionValue<number>;
width: MotionValue<number>;
size: { width: number; height: number };
onSettle: () => void;
onFail: () => void;
}) {
// A layout effect, so the first frame is drawn before the browser paints
// the canvas in place of the SVG badge.
useLayoutEffect(() => {
const canvas = canvasRef.current;
const svg = svgRef.current;
if (!live || !canvas || !svg) return;
const scale = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(size.width * scale);
canvas.height = Math.round(size.height * scale);
// Read the fill each time, so a theme switch between hovers is picked up.
const liquid = createLiquid(canvas, getComputedStyle(svg).fill, scale);
if (!liquid) {
onFail();
return;
}
const draw = () => {
const p = progress.get();
const d = offsetAt(p);
liquid.draw(d, widthAt(p, width.get()), smoothAt(d));
};
draw();
const settle = () => {
if (Math.abs(progress.get()) < 0.002 && Math.abs(progress.getVelocity()) < 0.05) onSettle();
};
const offChange = progress.on("change", () => {
draw();
settle();
});
const offComplete = progress.on("animationComplete", settle);
return () => {
offChange();
offComplete();
liquid.dispose();
};
}, [live, canvasRef, svgRef, progress, width, size.width, size.height, onSettle, onFail]);
}
export function MorphLink({
badge = "LIS",
title = "Lisbon Studio",
label = "Get directions",
href = "https://maps.google.com",
target = "_blank",
rel = "noreferrer",
color,
className,
}: MorphLinkProps) {
const reduceMotion = useReducedMotion();
const [active, setActive] = useState(false);
const [live, setLive] = useState(false);
const [noWebGL, setNoWebGL] = useState(false);
const liquid = !reduceMotion && !noWebGL;
// 0 at rest, 1 once the pill has fully grown. Everything else is read off
// this one value, so a hover that ends halfway reverses from where it is.
const progress = useMotionValue(0);
const width = useMotionValue(2 * R);
const svgRef = useRef<SVGSVGElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const [labelWidth, setLabelWidth] = useState(0);
useLayoutEffect(() => {
const el = labelRef.current;
if (!el) return;
const measure = () => {
setLabelWidth(el.scrollWidth);
width.set(el.scrollWidth);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [width]);
useEffect(() => {
if (reduceMotion) {
progress.jump(active ? 1 : 0);
return;
}
// Starts from the current value and velocity, so it reverses mid-flight.
const animation = animate(progress, active ? 1 : 0, active ? ENTER : EXIT);
return () => animation.stop();
}, [active, reduceMotion, progress]);
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
}, [active]);
const settle = useCallback(() => {
if (!activeRef.current) setLive(false);
}, []);
const fail = useCallback(() => {
setNoWebGL(true);
setLive(false);
}, []);
const canvasSize = {
width: Math.ceil(OVERSHOOT * REST + widthAt(OVERSHOOT, Math.max(labelWidth, 2 * R)) + 2 * BLEED),
height: 2 * R + 2 * BLEED,
};
useLiquid({
live: live && liquid,
canvasRef,
svgRef,
progress,
width,
size: canvasSize,
onSettle: settle,
onFail: fail,
});
const offset = useTransform(progress, offsetAt);
const pillWidth = useTransform([progress, width], ([p, w]: number[]) => widthAt(p, w));
const shape = useTransform([offset, pillWidth], ([d, w]: number[]) => pill(d, w));
const titleOpacity = useTransform(progress, [0, 0.3], [1, 0]);
const labelOpacity = useTransform(progress, [0.35, 0.8], [0, 1]);
const on = () => {
setActive(true);
setLive(true);
};
const off = () => setActive(false);
return (
<a
href={href}
target={target}
rel={rel}
onPointerEnter={on}
onPointerLeave={off}
onFocus={on}
onBlur={off}
style={{ minWidth: REST + labelWidth }}
className={cn(
"relative inline-flex h-7 items-center gap-2 rounded-full outline-offset-4 outline-ring select-none focus-visible:outline-2",
className
)}
>
<svg
ref={svgRef}
aria-hidden
width={2 * R}
height={2 * R}
className={cn("absolute top-0 left-0 overflow-visible", !color && "fill-primary")}
// The canvas draws the badge too. Hidden in render, not in an effect,
// so the swap back to the SVG lands in the same commit that removes
// the canvas and no frame is left without a badge.
style={{ fill: color, opacity: live && liquid ? 0 : undefined }}
>
<circle cx={R} cy={R} r={R} />
{!liquid && <motion.path d={shape} />}
</svg>
{live && liquid && (
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute"
style={{
top: -BLEED,
left: -BLEED,
width: canvasSize.width,
height: canvasSize.height,
}}
/>
)}
<span
aria-hidden
className="relative flex size-7 shrink-0 items-center justify-center text-[9px] font-semibold tracking-wide text-primary-foreground"
>
{badge}
</span>
<motion.span
style={{ opacity: titleOpacity }}
className="text-base font-medium tracking-tight whitespace-nowrap text-foreground"
>
{title}
</motion.span>
{/* Clipped to the pill, so the label is uncovered as the pill grows. */}
<motion.span
style={{ x: offset, width: pillWidth }}
className="pointer-events-none absolute top-0 left-0 h-7 overflow-hidden"
>
<motion.span
ref={labelRef}
style={{ opacity: labelOpacity }}
className={cn(
"flex h-7 w-max items-center px-3.5 text-sm font-medium whitespace-nowrap",
color ? "text-white" : "text-primary-foreground"
)}
>
{label}
</motion.span>
</motion.span>
</a>
);
}