Haptic Button React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React haptic button that feels native: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode.
Tap it and it dips on a quick spring while a ripple spreads from under your finger; phones play a light tick (touch and pen only, a mouse click never vibrates). With onLongPress, holding a finger or pen for 450ms plays a medium tick and runs the secondary action; a right click, Shift+F10 or the ContextMenu key do the same, and the click after a long press is swallowed. In hold mode a click does nothing: hold the finger, the mouse button, Space or Enter and the button fills from the start edge; let go early and the fill springs back, a quick tap shakes the button, plays a warning tick and shows a "Hold to confirm" chip above it. Moving more than 8px, leaving the button, switching tabs or blurring the window cancels. When the fill completes the button pops, onConfirm runs (a promise shows the spinner), then a check replaces the label for 1.2s with a success tick; a rejected promise shakes with an error tick. Screen readers, voice control and switch access, which activate with a plain click and cannot hold, get a two-step path: the first activation shows and announces "Activate again to confirm", a second one within 4s confirms. Outcomes are announced to screen readers. Under reduced motion there is no dip, ripple, pop or shake; the fill still grows.
Haptic Button is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add haptic-button, together with Haptics, Motion presets, Tones, UIProvider and useLongPress and the npm packages lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add haptic-buttonHaptic Button is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import HapticButton from @/components/ui/haptic-button.
import { Trash2 } from "lucide-react"
import { HapticButton } from "@/components/ui/haptic-button"
export function DeleteClient({ onDelete }: { onDelete: () => Promise<void> }) {
return (
<HapticButton
tone="danger"
variant="soft"
mode="hold"
holdDuration={1200}
leftIcon={<Trash2 />}
fullWidth
onConfirm={onDelete}
labels={{
holdHint: "Hold to delete",
confirmed: "Deleted",
activateAgain: "Activate again to delete",
}}
>
Delete Old Mill Bakery
</HapticButton>
)
}Props
childrendefault"Send"ReactNodeThe label. For icon sizes pass the icon and an aria-label. The playground swaps the sample for "Hold to send" in hold mode.
variantdefault"solid""solid" | "soft" | "outline" | "ghost"The button's variants (no link: a link has no surface to ripple or fill).
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Semantic color from the design tokens. danger for a hold to delete, brand for a hold to pay.
colordefault""stringAny CSS color. Replaces the tone in every variant; text on a solid fill turns black or white by lightness.
sizedefault"md""sm" | "md" | "lg" | "icon-sm" | "icon" | "icon-lg"The button's sizes: 32 / 40 / 48px, square icon sizes. On touch screens md grows to 44px and sm gets a 44px hit area.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled per size like the button. The ripple and the fill follow it.
effectdefault"both""scale" | "ripple" | "both" | "none"Press feedback: the 0.97 dip on a snappy spring, a ripple spreading from the touch point (keyboard presses ripple from the center), both, or none. In hold mode the fill replaces the ripple.
rippleColordefault""stringThe ripple color. Empty uses the text color at 16%; a custom color shows at 30%.
loadingdefaultfalsebooleanSpinner in place of the label, width kept, aria-busy; presses and holds are ignored and focus stays. Also on while an onConfirm promise runs.
loadingTextReactNodeText next to the spinner while loading. The button reserves room for it.
disableddefaultfalsebooleanDims the button and blocks presses, holds and long presses.
fullWidthdefaultfalsebooleanStretches to the container width, the usual choice for the main action on a phone.
modedefault"press""press" | "hold"press is a normal button. hold: a click does nothing; holding a finger, the mouse button, Space or Enter for holdDuration fills the button and calls onConfirm. For destructive or money actions that deserve more than a tap but less than a dialog.
holdDurationdefault1200numberMilliseconds of holding to confirm, clamped to 300..5000.
showSuccessdefaulttruebooleanAfter onConfirm (and its promise) a check replaces the label for 1.2s and the button gets data-confirmed.
hapticsdefaulttruebooleanHaptic ticks through lib/haptics on touch and pen presses (never for a mouse or on desktops). Turn off app-wide with configureHaptics({ enabled: false }).
hapticKinddefault"light""selection" | "light" | "medium" | "heavy" | "success" | "warning" | "error"The press-mode tick. Hold mode always plays light at the start, success on confirm, warning on a too-short tap and error on a rejected promise; a long press plays medium.
typedefault"button""button" | "submit" | "reset"Defaults to "button". In hold mode a submit button never submits on a click; submit from onConfirm.
leftIconReactNodeAn icon before the label. Unsized svgs follow the size (14 / 16 / 18px).
rightIconReactNodeAn icon after the label.
spinnerReactNodeReplaces the default spinner.
onClick(event: MouseEvent<HTMLButtonElement>) => voidPress mode only, and not while loading or disabled. Hold mode never calls it: put the action in onConfirm.
onConfirm() => (void | Promise<unknown>)Hold mode's action, called when the fill completes. A promise shows the loading state, then the check; a rejection shakes the button, plays an error tick and announces labels.failed.
onHoldStart() => voidA hold began (finger, pen, mouse button, Space or Enter).
onHoldCancel() => voidA hold ended before the fill completed: released, moved past 8px, left the button, window blurred, or disabled.
onLongPress(event: LongPressEvent<HTMLButtonElement>) => voidPress mode's secondary action: a 450ms touch or pen hold (through useLongPress), a right click, Shift+F10 or the ContextMenu key. The click after a long press is swallowed; screen readers hear labels.longPressHint.
labelsPartial<HapticButtonLabels>Strings: holdHint "Hold to confirm", holding "Keep holding", confirmed "Done", failed "That did not work, try again", longPressHint "Press and hold for more options", activateAgain "Activate again to confirm" (hold mode, after a click from assistive tech). DEFAULT_HAPTIC_BUTTON_LABELS holds the defaults.
classNamesPartial<Record<"effects" | "ripple" | "fill" | "content" | "icon" | "spinner" | "check" | "hint", string>>Extra classes for the inner parts: the effects layer, each ripple, the hold fill, the label, icons, spinner, check and hint chip.
classNamestringExtra classes for the button itself, merged last. Override the tone vars here, e.g. [--tone-foreground:white].
Parts
DEFAULT_HAPTIC_BUTTON_LABELS
constantThe English strings: holdHint ("Hold to confirm"), holding ("Keep holding"), confirmed ("Done"), failed, longPressHint ("Press and hold for more options") and activateAgain, which assistive tech users see in hold mode. Spread it into labels to translate a few. Type: HapticButtonLabels.
Examples
Effects
scale, ripple, both or none; ghost and soft buttons ripple over a photo too.
Hold to delete
mode="hold" with the danger tone: the row fades out only after a full hold.
Hold to pay
An async onConfirm shows the spinner, then the check. holdDuration 1500ms.
Long press for more
onLongPress opens an action sheet; right click and Shift+F10 work on desktop.
Icon buttons
Toggles with aria-pressed; the heart pops on the bouncy spring.
Sizes
sm, md and lg in solid and outline, the button's own scale.
Custom color
color="#0F766E" with a light rippleColor.
Files
- components/ui/haptic-button.tsx
FAQ
Is the React Haptic Button component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Haptic Button?
Run npx wingo-ui@latest add haptic-button. It also adds the Wingo UI items it builds on (Haptics, Motion presets, Tones, UIProvider and useLongPress) and the npm packages lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/haptic-button.
See also
More React mobile components
Articles using this component
- Web App That Feels Native on a Phone: 12 React Fixes
Mobile UI in React11 min read
- Minimum Touch Target Size: WCAG, iOS, Android and Tailwind
Mobile UI in React11 min read
- Mobile-First React Components: 9 Rules With Code
Mobile UI in React21 min read
- Web Vibration API: Haptic Feedback on Android and iPhone
Mobile UI in React11 min read