Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Tar button

New

A glossy black button made of sticky tar: drag away from it and it pulls out into strands that sag, twist and snap one by one.

tar-button/tar-button.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • One call to action that should be remembered: a contact or “let’s talk” button at the end of a portfolio or a landing page.
  • A page with room around the button, about 400 px tall or more, so the strands have somewhere to go.
  • A playful brand, where touching the button is part of the message.
  • A pointer or touch screen; the button still works as a plain button by keyboard.

When not to use

  • Forms, toolbars and dialogs, or anywhere several buttons sit together. Use a plain button.
  • A destructive or serious action, such as paying or deleting: the play gets in the way.
  • Tight spaces, under about 360 × 300 px: the strands need room.
  • A page that already moves a lot; one tar button is plenty.

You can add this component with the shadcn CLI or by hand:

Into components/tar-button of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/tar-button.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

contact.tsx
'use client';import { TarButton } from './tar-button';export function Contact() {  return (    <TarButton      label="let’s collaborate"      strands={7}      viscosity={0.5}      onPress={() => window.open('mailto:hello@example.com')}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A black pill on a pale field, glossy as wet tar, with reflections that slide across it. Press it and drag away, from any side, and its surface bulges after the pointer and pulls out into thin strands of different thickness that stretch to the hand, sag, twist round each other and snap one by one when pulled too far. Let go and the rest whip back, leaving drips and little hairs that sink into the pill. A plain click, Enter or Space still presses it.

  • The pill and the strands are drawn on one canvas over the whole stage, at the screen’s density. The button is a real <button> laid over the pill by the stylesheet, transparent but for its words; the script measures it and draws the tar under it.
  • The pill’s edge is walked by arc length round the capsule. Its outline is 128 points of that walk, each pushed out by springs: one rounded swelling centred on the pull, mostly along the edge’s normal so it eases back into the capsule on both sides, and a small bump at the root of each strand, along it. The springs are underdamped, so the surface wobbles when let go.
  • Each strand is a verlet rope of 18 nodes, pinned at its root on the deformed edge and at the hand, each at a small offset that turns so the strands twist round each other. Its rest length trails the distance to the hand at a rate set by the viscosity: a quick pull draws them taut, a slow one leaves them slack, and gravity sags them.
  • A strand snaps when the hand goes past its own reach, or when it is yanked well past its rest length. It splits: the part on the pill shrinks to a stub, whips back and hangs as a drip or stands as a hair (a pull along the edge’s normal fights gravity), then sinks in; the part in the hand shrinks and fades. Letting go does the same to the strands still held.
  • A strand is drawn as a filled outline, thinner the more it is stretched, flared into a fillet at its root and a bead at a free end, with a glint along the thick ones; thin filaments join neighbours until they part too far. The pill gets a long soft shadow, a window’s reflection at each end, soft bands of light along the top that slide as the surface moves, and a bright bevel along the bottom.
  • Frames run only while something moves and the stage is on screen. The canvas reads its colours from two hidden probes that carry the tokens, and repaints when a 1 ms transition on one says the scheme changed. The glove is drawn in SVG and used as the cursor, and as the demo’s pointer, which grabs from below, from the right and from the left in a loop.
PropTypeDefaultDescription
labelstring'let’s collaborate'The button’s words.
strandsnumber7How many strands a pull draws out of the tar, 1 to 16.
viscositynumber0.5How thick the tar is, 0 to 1: thicker tar lags further behind the pointer, sags less and stretches further before a strand snaps.
onPress() => void—Called when the button is pressed: a click, a tap, Enter or Space. A pull that ends away from where it started is not a press.
onSnap(held: number) => void—Called when a strand snaps, with how many are still held.
onRelease() => void—Called when the reader lets go of a pull.
defaultPull{ x: number; y: number }—Opens held in a pull toward this point, in pill widths from the pill’s middle (x right, y down), for a still view of the strands; turns the demo off.
autoplaybooleantrueWhile on screen and untouched, a glove pulls at the tar on its own; any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
classNamestring—Classes for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

tar-button.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useRef,  type CSSProperties,  type PointerEvent as ReactPointerEvent,} from 'react';import { LABEL, STRANDS, VISCOSITY } from './data';import { DemoPointer, pointerAt, runDemo, type Point } from './demo-pointer';import { OPEN_TIP, gloveCursor } from './glove';import { Tar, type Palette } from './tar';import { useAutoplay } from './use-autoplay';import styles from './tar-button.module.css';/* * Tar button: a glossy black pill made of sticky tar. Press it and drag away, from any side, and * its surface pulls out into strands that stretch to the pointer, sag, twist round each other and * snap one by one; let go and the rest whip back, leaving drips and hairs that sink into the pill. * A plain click, Enter or Space still presses it. *
tar-button.module.css
/* Tar button (tar-button.tsx): a near-white field with the black pill in its middle, or at night a   near-black one where the tar keeps its black and reads by its rims and reflections. The pill is   half the stage's width (200 to 400 px), 10 : 3; the stage is 72% as tall as it is wide.   The canvas draws the pill and its strands; it cannot read these tokens, so the two probes at   the end carry them as colours, and a 1 ms transition on the first tells the script when the   scheme changes. Colours are light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). */.root {  /* The field, and the words on the pill. */  --stage: #f3f3f3;  --label: #f2f2f2;  /* The tar: its body, a little lighter at the top and the bottom; the reflections on it. */  --tar: #050506;  --tar-top: #1b1b1d;  --tar-bottom: #111113;  --sheen: rgb(214 220 224 / 0.42);  --rim: rgb(236 238 242 / 0.62);  /* A hairline of light round the pill and the strands: none by day. */  --edge: rgb(255 255 255 / 0);  /* Shadows: the pill's long soft one and its near one, the strands'. */  --shadow: rgb(0 0 0 / 0.2);
tar.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The tar: the pill's body, and the strands a pull draws out of it, simulated and drawn on a * canvas. No React here; tar-button.tsx feeds it the pointer and a frame clock. * * - The pill is a capsule, walked by arc length s round its edge (clockwise from the top left). *   Its outline is 128 points of that walk, each pushed outwards by the springs on top of it: one *   rounded swelling toward the pointer, and one narrow bump at the root of each strand, along it. *   Springs that are not critically damped wobble when let go. * - A strand is a verlet rope of up to 18 nodes, pinned at its root on the deformed outline and, *   while held, at the hand (each one at its own small offset that turns, so they twist round *   each other). Its rest length trails the distance to the hand at a rate set by the viscosity: *   a quick pull draws them taut, a slow one leaves them slack, and gravity sags them. * - A held strand snaps when the hand gets further than its own reach, or when it is yanked well *   past its rest length. It splits: the part on the pill whips back (its rest length shrinks to a *   stub), hangs as a drip or stands as a hair (a pull toward the edge's normal fights gravity), *   then sinks into the pill; the part in the hand shrinks and fades. Letting go does the same to *   every strand still held. * - Thin filaments join neighbouring strands and break when they part too far. * - Each strand is drawn as a filled outline: thin in its middle, thinner the more it is *   stretched, flared into a fillet at its root and a bead at a free end. */
glove.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The cartoon glove: white with a black outline, open with the index finger up, or closed in a * fist that holds the tar. The same drawings make the stage's cursor (as SVG data URIs) and the * demo's pointer. Each is drawn back to front in a 48 × 48 box, every piece white over the ones * behind it, so the outlines that cross read as the creases between fingers. *//** The cuff, the same for both hands. */const CUFF =  'M15 37.5h17.5a2.5 2.5 0 0 1 2.5 2.5v4a2.5 2.5 0 0 1-2.5 2.5H15a2.5 2.5 0 0 1-2.5-2.5v-4a2.5 2.5 0 0 1 2.5-2.5z';const CUFF_FOLD = 'M14.6 41.6c6 1 12 1 18 0';const OPEN = [  CUFF,  // The index finger, then the three curled ones, the back of the hand and the thumb.  'M13.5 5.5a3.6 3.6 0 0 1 7.2 0V24h-7.2z',  'M21 21a4 4 0 1 0 8 0a4 4 0 1 0-8 0z',  'M26.8 22.4a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0-7.6 0z',  'M31.2 25.8a3.4 3.4 0 1 0 6.8 0a3.4 3.4 0 1 0-6.8 0z',  'M12.6 22.5h14c6 0 10.4 3.4 10.4 9.2v.6c0 3.9-2.7 6.4-6.4 6.4H17.4c-3.4 0-5.6-2.5-5.6-5.6v-8.8c0-1 .3-1.8.8-1.8z',  'M8.3 26.2c1.6-1.8 4.6-1.6 6.5.4l4.2 4.6c1.2 1.3 1 3.2-.4 4.2-1.3.9-3 .7-4-.5L8.4 29.6c-.9-1-.9-2.4-.1-3.4z',];
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* The tar button's defaults. *//** The button's words. */export const LABEL = 'let’s collaborate';/** How many strands a pull draws out of the tar. */export const STRANDS = 9;/** * How thick the tar is, from 0 to 1: thicker tar lags further behind the pointer, sags less and * stretches further before a strand snaps. */export const VISCOSITY = 0.5;
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';import { GRAB_TIP, Glove, OPEN_TIP } from './glove';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API. The tar follows the pointer * frame by frame, so `pointerAt` reads where the flight has it now. The pointer is the glove, * open or closed: two HTML boxes that swap by their visibility. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer to `to`, in the stage's own px, in `ms`. */
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The pill is a native button named by its words: Tab reaches it, Enter and Space press it, and a click or a tap still presses it however sticky it looks.
  • The pull is decoration on top of the button: nothing depends on it, and it never takes the focus or changes what the button does.
  • A visible focus ring follows the pill’s shape. The canvas, the probes and the demo’s glove are hidden from assistive technology.
  • Under reduced motion the demo does not play and the springs are critically damped: the tar still follows a pull, without the wobble.
  • Touch starting on the pill pulls instead of scrolling the page; touch anywhere else on the stage scrolls as usual.