a finger's tap on a phone screen, as iOS shows touches: down, pressed, lifted
npx shadcn add @ag/touchAlready installed? Preview an update
npx shadcn add @ag/touch --dry-runYour 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), atx,yin 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, asmacos's pointer shows a Mac's clicks.
also called tap, finger, show touches
shelf devices & demos
the demo above, verbatim
"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
// 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}
/>
)
}