← ui

touch

ui

a finger's tap on a phone screen, as iOS shows touches: down, pressed, lifted

npx shadcn add @ag/touch
Already installed? Preview an update
npx shadcn add @ag/touch --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • New: Touch, a finger on a phone's screen as iOS draws one with Show Touches: a soft white disc that comes down, holds and lifts over one tap (tap, 0..1), at x, y in the box it presses on. touchPressed(tap) says when the control under it is pressed, so the control lights in step. A phone story's taps, as macos's pointer shows a Mac's clicks.
Updated 2026-10-15 · release notes

also called tap, finger, show touches

shelf devices & demos

the demo above, verbatim

touch.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Touch, touchPressed } from "@/registry/base-nova/ui/touch"

// #region instants
// One tap, four instants of it: coming down, pressed, held, lifting.
const INSTANTS = [0.15, 0.4, 0.6, 0.85]
// #endregion

export default function TouchDemo() {
  return (
    <div className="space-y-16">
      <Sample
        name="tap"
        glance="wide"
        title="a tap, four instants"
        description="the button pressed while the finger is down (touchPressed)"
        with="instants"
      >
        <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
          {INSTANTS.map((tap) => (
            <div
              key={tap}
              className="relative grid aspect-[402/300] place-items-center rounded-2xl bg-neutral-900 [container-type:inline-size]"
            >
              <Button
                material="glass"
                scheme="dark"
                className={touchPressed(tap) ? "brightness-150" : undefined}
              >
                save contact
              </Button>
              <Touch x="50%" y="50%" tap={tap} />
              <span className="absolute bottom-2 left-3 font-mono text-[11px] text-white/60">
                {tap}
              </span>
            </div>
          ))}
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

touch.tsx
// A FINGER ON A SCREEN, as iOS draws one with Show Touches and in a screen
// recording: a soft white disc where the finger lands. A phone has no pointer, so
// a story told on one shows its taps with this (`macos` shows a Mac's clicks with
// its pointer). One tap over `tap`, 0..1: the finger comes down, holds the
// control pressed, lifts. Placed in the box it presses on (positioned), at `x`
// and `y` from that box's top-left. Display-only: no role, nothing focusable.

import type * as React from "react"
import { cn } from "@/lib/utils"

/** A tap's phases, shares of it: down until `down`, held until `lift`, lifting
 *  after. */
export const TOUCH = { down: 0.3, lift: 0.7 } as const

/** Whether the control under the finger is pressed at `tap`: from the moment the
 *  finger is down until it lifts, so the control lights in step with the disc. */
export function touchPressed(tap: number): boolean {
  return tap >= TOUCH.down && tap < TOUCH.lift
}

export interface TouchProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Where the finger lands, any CSS lengths, from the box's top-left corner. */
  x: string
  y: string
  /** How far through the tap, 0..1. Outside it, no finger. */
  tap: number
  /** The disc's diameter, any CSS length: by default a fingertip on a phone
   *  drawn at its real size, 11% of the box's width (44 of a phone's 402
   *  points), so the box must be a size container. */
  size?: string
}

export function Touch({
  x,
  y,
  tap,
  size = "11cqw",
  className,
  style,
  ...props
}: TouchProps) {
  if (tap <= 0 || tap >= 1) return null
  // Down: it lands from a little larger and fades in. Held: pressed in. Lifting:
  // it lets go outward and fades.
  const { down, lift } = TOUCH
  const opacity =
    tap < down ? tap / down : tap >= lift ? (1 - tap) / (1 - lift) : 1
  const scale =
    tap < down
      ? 1.4 - 0.5 * (tap / down)
      : tap >= lift
        ? 0.9 + 0.3 * ((tap - lift) / (1 - lift))
        : 0.9
  return (
    <div
      aria-hidden
      data-slot="touch"
      className={cn(
        "pointer-events-none absolute top-(--ag-touch-y) left-(--ag-touch-x) z-50 size-(--ag-touch-size) -translate-x-1/2 -translate-y-1/2 scale-(--ag-touch-scale) rounded-full bg-white/40 opacity-(--ag-touch-opacity) shadow-[inset_0_0_0_1.5px_rgb(255_255_255/0.75),0_0_0_1px_rgb(0_0_0/0.12),0_2px_10px_rgb(0_0_0/0.3)]",
        className,
      )}
      style={
        {
          "--ag-touch-x": x,
          "--ag-touch-y": y,
          "--ag-touch-size": size,
          "--ag-touch-scale": String(scale),
          "--ag-touch-opacity": String(opacity),
          ...style,
        } as React.CSSProperties
      }
      {...props}
    />
  )
}