Building a Web App that Vibrates the Smartphone in v0: Implement and Deploy to Vercel with a Single Prompt

Building a Web App that Vibrates the Smartphone in v0: Implement and Deploy to Vercel with a Single Prompt

Generated a Next.js app with a single prompt in v0 and deployed it to Vercel. I tested Step and Ramp vibration patterns using the Vibration API on Chrome on Pixel 8 Pro, and explained the limitation of not being able to specify strength (amplitude).
2026.01.24

This page has been translated by machine translation. View original

Introduction

When you press a button on a smartphone's web page and the device vibrates, it enhances the tactile feedback of the operation. Even with a simple demo, you can feel the difference in experience with haptic feedback. In this article, we will verify if we can make Android devices vibrate with a web app implemented in v0.

What is v0

v0 is an AI development platform provided by Vercel. It generates web apps like Next.js from natural language instructions and allows one-click deployment to Vercel.

vibration-demo-gif

Scope of this article

We will use the Vibration API to make smartphones vibrate. The Vibration API is not a feature that works in all browsers. In particular, Safari and Safari on iOS are not supported. Therefore, this article assumes Android devices for operation verification. When accessed from iOS, the screen will indicate that it is not supported and disable the vibration button.

The verification environment is as follows:

  • Device: Pixel 8 Pro
  • Browser: Chrome

Note that the Vibration API does not allow specifying the strength (amplitude) of vibration. You can only specify the duration of vibration and pause (in ms). Therefore, in this demo, we will take the following approach for the two vibration patterns, Step and Ramp, used for verification:

  • Step: Extend the ON time of vibration every 1,000 ms to make it feel stronger
  • Ramp: Repeat short vibration pulses and shorten the stop intervals to make it feel like the density is increasing

Target audience

  • Those who want to try generating web apps from prompts using v0
  • Those who want to understand the range of expressions possible with the Web Vibration API and browser compatibility
  • Those who want to know the steps to check vibration on an actual device using an Android device and Chrome

References

Prompt input to v0

Below is the full text of the prompt input to v0. I've instructed to express Step and Ramp with time patterns, assuming that strength (amplitude) cannot be specified.

Build a production-ready Next.js (App Router) web app that demonstrates device vibration using the Web Vibration API.

Goals:
- One-click deployable on Vercel (no extra setup).
- Mobile-first UI, dark theme, clean typography.
- This is a web app. Do NOT generate React Native / Expo.

UI requirements:
- Title: Vibration Demo
- Subtitle: Works on Android browsers that support navigator.vibrate(). iOS Safari is not supported.
- A status panel showing:
  - Platform info (userAgent, isMobile)
  - Vibration support: supported / not supported
  - Last action (Idle / Step running / Ramp running / Stopped / Completed)
- Buttons (large, accessible):
  1) Step (5 levels / 5s)
  2) Ramp (smooth / 5s)
  3) Stop

Behavior requirements:
- Use navigator.vibrate() ONLY inside direct user gestures (button click).
- Feature detect safely:
  - const canVibrate = typeof navigator !== 'undefined' && 'vibrate' in navigator
- If not supported:
  - Disable Step/Ramp buttons
  - Show a clear message explaining iOS Safari does not support Vibration API.
- Stop button always works:
  - navigator.vibrate(0)
  - Also cancels any timers.
- Implement Step as a single vibration pattern array lasting ~5 seconds:
  - Since intensity control is not available in Vibration API, represent stronger as longer ON time within each 1000ms step.
  - Example: five steps where ON duration increases each second (e.g., 200ms, 400ms, 600ms, 800ms, 1000ms) with OFF time filling the remainder.
- Implement Ramp as a single pattern array lasting ~5 seconds:
  - Simulate smooth ramp by short pulses with gradually shorter pauses (frequency increases).
- Use React state and refs to prevent double-runs and to support canceling.
- Add a small Test tips section:
  - Must be HTTPS (Vercel provides this)
  - Must tap buttons (user activation)
  - Some devices / modes may suppress vibration
  - iOS Safari: not supported

Output:
- Provide complete code for all files needed.
- Keep dependencies minimal.
- Use Tailwind CSS for styling (or plain CSS modules if you prefer), but ensure it works out of the box.

This prompt clarifies the design of Step and Ramp as "pseudo changes using time patterns." I recorded a GIF animation (5x speed) showing the app being generated.

vibration-demo-gif

Structure of the generated app

The screen of the app generated by v0 looks like this:

created app

The structure was based on Next.js (App Router).

app/layout.tsx: Metadata, icon, integration of @vercel/analytics

app/layout.tsx
import React from "react"
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'

const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: 'Vibration Demo',
  description: 'Test device vibration using the Web Vibration API',
  generator: 'v0.app',
  icons: {
    icon: [
      {
        url: '/icon-light-32x32.png',
        media: '(prefers-color-scheme: light)',
      },
      {
        url: '/icon-dark-32x32.png',
        media: '(prefers-color-scheme: dark)',
      },
      {
        url: '/icon.svg',
        type: 'image/svg+xml',
      },
    ],
    apple: '/apple-icon.png',
  },
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en" className="dark">
      <body className={`font-sans antialiased`}>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

app/page.tsx: Main screen (single page)

app/page.tsx
"use client"

import { useState, useRef, useEffect, useCallback } from "react"
import { Smartphone, Vibrate, Square, TrendingUp, AlertTriangle, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"

type ActionStatus = "Idle" | "Step running" | "Ramp running" | "Stopped" | "Completed"

export default function VibrationDemo() {
  const [canVibrate, setCanVibrate] = useState<boolean | null>(null)
  const [isMobile, setIsMobile] = useState<boolean | null>(null)
  const [userAgent, setUserAgent] = useState<string>("")
  const [status, setStatus] = useState<ActionStatus>("Idle")
  const [isRunning, setIsRunning] = useState(false)

  const timeoutRef = useRef<NodeJS.Timeout | null>(null)

  useEffect(() => {
    const supported = typeof navigator !== "undefined" && "vibrate" in navigator
    setCanVibrate(supported)

    const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""
    setUserAgent(ua)

    const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)
    setIsMobile(mobile)
  }, [])

  const stopVibration = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
      timeoutRef.current = null
    }
    if (canVibrate) {
      navigator.vibrate(0)
    }
    setIsRunning(false)
    setStatus("Stopped")
  }, [canVibrate])

  const runStep = useCallback(() => {
    if (!canVibrate || isRunning) return

    setIsRunning(true)
    setStatus("Step running")

    // Step pattern: 5 levels over ~5 seconds
    // Each step is 1000ms total, ON duration increases: 200, 400, 600, 800, 1000
    // Pattern: [on, off, on, off, on, off, on, off, on]
    const pattern = [
      200, 800,  // Step 1: 200ms on, 800ms off
      400, 600,  // Step 2: 400ms on, 600ms off
      600, 400,  // Step 3: 600ms on, 400ms off
      800, 200,  // Step 4: 800ms on, 200ms off
      1000       // Step 5: 1000ms on (no off needed at end)
    ]

    navigator.vibrate(pattern)

    // Pattern duration: 200+800+400+600+600+400+800+200+1000 = 5000ms
    timeoutRef.current = setTimeout(() => {
      setIsRunning(false)
      setStatus("Completed")
    }, 5000)
  }, [canVibrate, isRunning])

  const runRamp = useCallback(() => {
    if (!canVibrate || isRunning) return

    setIsRunning(true)
    setStatus("Ramp running")

    // Ramp pattern: simulate smooth ramp by short pulses with gradually shorter pauses
    // Total duration ~5 seconds, frequency increases over time
    const pattern: number[] = []
    let totalDuration = 0
    const targetDuration = 5000

    // Start with long pauses (low frequency), gradually shorten pauses (high frequency)
    // Pulse duration stays constant at 50ms, pause decreases from 150ms to 10ms
    let pulseDuration = 50
    let pauseDuration = 150
    const pauseDecrement = 5

    while (totalDuration < targetDuration) {
      pattern.push(pulseDuration)
      totalDuration += pulseDuration

      if (totalDuration >= targetDuration) break

      pattern.push(pauseDuration)
      totalDuration += pauseDuration

      // Decrease pause duration to increase frequency
      pauseDuration = Math.max(10, pauseDuration - pauseDecrement)
    }

    navigator.vibrate(pattern)

    timeoutRef.current = setTimeout(() => {
      setIsRunning(false)
      setStatus("Completed")
    }, totalDuration)
  }, [canVibrate, isRunning])

  const handleStop = () => {
    stopVibration()
  }

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
      if (typeof navigator !== "undefined" && "vibrate" in navigator) {
        navigator.vibrate(0)
      }
    }
  }, [])

  return (
    <main className="min-h-screen bg-background p-4 md:p-8">
      <div className="mx-auto max-w-md space-y-6">
        {/* Header */}
        <div className="space-y-2 text-center">
          <div className="flex items-center justify-center gap-2">
            <Vibrate className="h-8 w-8 text-foreground" />
            <h1 className="text-3xl font-bold tracking-tight text-foreground">
              Vibration Demo
            </h1>
          </div>
          <p className="text-muted-foreground text-balance">
            Works on Android browsers that support navigator.vibrate(). iOS Safari is not supported.
          </p>
        </div>

        {/* Status Panel */}
        <Card className="border-border bg-card">
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-lg text-card-foreground">
              <Smartphone className="h-5 w-5" />
              Status
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-3">
            <div className="space-y-2 text-sm">
              <div className="flex justify-between">
                <span className="text-muted-foreground">Platform:</span>
                <span className="text-card-foreground font-medium">
                  {isMobile === null ? "Detecting..." : isMobile ? "Mobile" : "Desktop"}
                </span>
              </div>
              <div className="flex justify-between">
                <span className="text-muted-foreground">Vibration:</span>
                <span className={`font-medium ${canVibrate ? "text-green-500" : "text-red-500"}`}>
                  {canVibrate === null ? "Detecting..." : canVibrate ? "Supported" : "Not supported"}
                </span>
              </div>
              <div className="flex justify-between">
                <span className="text-muted-foreground">Last action:</span>
                <span className={`font-medium ${
                  status === "Idle" ? "text-muted-foreground" :
                  status === "Stopped" ? "text-yellow-500" :
                  status === "Completed" ? "text-green-500" :
                  "text-blue-500"
                }`}>
                  {status}
                </span>
              </div>
            </div>
            {userAgent && (
              <div className="pt-2 border-t border-border">
                <p className="text-xs text-muted-foreground break-all">
                  {userAgent}
                </p>
              </div>
            )}
          </CardContent>
        </Card>

        {/* Not Supported Warning */}
        {canVibrate === false && (
          <Card className="border-yellow-500/50 bg-yellow-500/10">
            <CardContent className="flex items-start gap-3 pt-6">
              <AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
              <div className="space-y-1">
                <p className="font-medium text-yellow-500">Vibration API Not Supported</p>
                <p className="text-sm text-muted-foreground">
                  Your browser does not support the Vibration API. iOS Safari and some desktop browsers do not implement this feature.
                </p>
              </div>
            </CardContent>
          </Card>
        )}

        {/* Control Buttons */}
        <Card className="border-border bg-card">
          <CardHeader className="pb-3">
            <CardTitle className="text-lg text-card-foreground">Controls</CardTitle>
            <CardDescription>
              Tap a button to start vibration
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-3">
            <Button
              onClick={runStep}
              disabled={!canVibrate || isRunning}
              size="lg"
              className="w-full h-14 text-lg gap-2"
            >
              <TrendingUp className="h-5 w-5" />
              Step (5 levels / 5s)
            </Button>

            <Button
              onClick={runRamp}
              disabled={!canVibrate || isRunning}
              size="lg"
              variant="secondary"
              className="w-full h-14 text-lg gap-2"
            >
              <Vibrate className="h-5 w-5" />
              Ramp (smooth / 5s)
            </Button>

            <Button
              onClick={handleStop}
              size="lg"
              variant="destructive"
              className="w-full h-14 text-lg gap-2"
            >
              <Square className="h-5 w-5" />
              Stop
            </Button>
          </CardContent>
        </Card>

        {/* Test Tips */}
        <Card className="border-border bg-card">
          <CardHeader className="pb-3">
            <CardTitle className="flex items-center gap-2 text-lg text-card-foreground">
              <Info className="h-5 w-5" />
              Test Tips
            </CardTitle>
          </CardHeader>
          <CardContent>
            <ul className="space-y-2 text-sm text-muted-foreground">
              <li className="flex items-start gap-2">
                <span className="text-green-500 mt-1"></span>
                <span>Must be served over <strong className="text-foreground">HTTPS</strong> (Vercel provides this)</span>
              </li>
              <li className="flex items-start gap-2">
                <span className="text-green-500 mt-1"></span>
                <span>Must <strong className="text-foreground">tap buttons</strong> (user activation required)</span>
              </li>
              <li className="flex items-start gap-2">
                <span className="text-yellow-500 mt-1"></span>
                <span>Some devices or silent modes may suppress vibration</span>
              </li>
              <li className="flex items-start gap-2">
                <span className="text-red-500 mt-1"></span>
                <span><strong className="text-foreground">iOS Safari:</strong> Not supported</span>
              </li>
            </ul>
          </CardContent>
        </Card>
      </div>
    </main>
  )
}

The UI was built with shadcn/ui-type components and Tailwind CSS, with icons from lucide-react. The page.tsx declares use client and implements accessing navigator on the client side.

Also, there were quite a lot of dependencies in package.json. While the prompt asked to keep dependencies minimal, v0 seems to have adopted a template that includes UI component sets.

package.json
{
  "name": "my-v0-project",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint ."
  },
  "dependencies": {
    "@hookform/resolvers": "^3.10.0",
    "@radix-ui/react-accordion": "1.2.2",
    "@radix-ui/react-alert-dialog": "1.1.4",
    "@radix-ui/react-aspect-ratio": "1.1.1",
    "@radix-ui/react-avatar": "1.1.2",
    "@radix-ui/react-checkbox": "1.1.3",
    "@radix-ui/react-collapsible": "1.1.2",
    "@radix-ui/react-context-menu": "2.2.4",
    "@radix-ui/react-dialog": "1.1.4",
    "@radix-ui/react-dropdown-menu": "2.1.4",
    "@radix-ui/react-hover-card": "1.1.4",
    "@radix-ui/react-label": "2.1.1",
    "@radix-ui/react-menubar": "1.1.4",
    "@radix-ui/react-navigation-menu": "1.2.3",
    "@radix-ui/react-popover": "1.1.4",
    "@radix-ui/react-progress": "1.1.1",
    "@radix-ui/react-radio-group": "1.2.2",
    "@radix-ui/react-scroll-area": "1.2.2",
    "@radix-ui/react-select": "2.1.4",
    "@radix-ui/react-separator": "1.1.1",
    "@radix-ui/react-slider": "1.2.2",
    "@radix-ui/react-slot": "1.1.1",
    "@radix-ui/react-switch": "1.1.2",
    "@radix-ui/react-tabs": "1.1.2",
    "@radix-ui/react-toast": "1.2.4",
    "@radix-ui/react-toggle": "1.1.1",
    "@radix-ui/react-toggle-group": "1.1.1",
    "@radix-ui/react-tooltip": "1.1.6",
    "@vercel/analytics": "1.3.1",
    "autoprefixer": "^10.4.20",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "cmdk": "1.0.4",
    "date-fns": "4.1.0",
    "embla-carousel-react": "8.5.1",
    "input-otp": "1.4.1",
    "lucide-react": "^0.454.0",
    "next": "16.0.10",
    "next-themes": "^0.4.6",
    "react": "19.2.0",
    "react-day-picker": "9.8.0",
    "react-dom": "19.2.0",
    "react-hook-form": "^7.60.0",
    "react-resizable-panels": "^2.1.7",
    "recharts": "2.15.4",
    "sonner": "^1.7.4",
    "tailwind-merge": "^3.3.1",
    "tailwindcss-animate": "^1.0.7",
    "vaul": "^1.1.2",
    "zod": "3.25.76"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4.1.9",
    "@types/node": "^22",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "postcss": "^8.5",
    "tailwindcss": "^4.1.9",
    "tw-animate-css": "1.3.3",
    "typescript": "^5"
  }
}

Compatibility detection

The Vibration API is not supported in all environments. We check for the presence of navigator.vibrate and reflect it on the screen.

const supported = typeof navigator !== "undefined" && "vibrate" in navigator
setCanVibrate(supported)

Safari browsers on iOS do not support it, so when opened on an iPhone, it will show "Not supported" and the buttons will be disabled.

Why user action is required

For security reasons, navigator.vibrate() requires execution permission based on explicit user action. It cannot be designed to automatically start vibration immediately after page display. It needs to be called after user interaction, such as a button click. This demo satisfies this condition by only calling it from buttons.

Step pattern

Step lasts for 5 seconds. It extends the ON time of vibration by 200 ms every second, making it feel like it's changing gradually.

const pattern = [
  200, 800,
  400, 600,
  600, 400,
  800, 200,
  1000
]

navigator.vibrate(pattern)

This array follows the format of the Vibration API, which alternately specifies vibration and stop times. The total is 5,000 ms, which matches the prompt's instructions.

Ramp pattern

Ramp repeats short vibration pulses over 5 seconds, gradually shortening the stop intervals. The generated implementation straightforwardly coded the prompt's instructions:

  • Fixed 50 ms vibration pulses
  • Starting with 150 ms stop time and decreasing by 5 ms each time
  • Minimum stop time of 10 ms
  • Accumulate the array until the total reaches around 5,000 ms

This method is not "smoothly increasing strength (amplitude)" but "increasing the frequency of vibration". Since the Vibration API cannot specify strength, it's designed to create the feeling through time patterns.

How Stop stops

Stop stops with navigator.vibrate(0). In this implementation, it also clearTimeout the completion process managed by setTimeout. This helps prevent inconsistencies with the displayed Status.

Deployment to Vercel

To access from Chrome on a Pixel 8 Pro and verify the vibration on the actual device, we deploy:

  1. Select Publish from the v0 generation screen and deploy to Vercel
    publish button
  2. Get the URL displayed after deployment is complete
    deploy url

Verification on actual device

I opened the deployed URL in Chrome on a Pixel 8 Pro and confirmed that the Status Vibration showed Supported.

When I pressed the Step button, a 5-second vibration occurred, and Last action changed from Step running to Completed. Step worked as intended.

demo top page

When I pressed the Ramp button, it vibrated and stopped intermittently, with the intervals becoming shorter toward the end. Rather than smoothly getting stronger, it felt like a series of pulses (bup-bup-bup) with increasing density.

The Stop button was effective even during execution, stopping the vibration and changing Last action to Stopped.

Considerations

Reasons Why Ramp May Not Feel Smooth

The jerky feeling of the Ramp implementation is due to constraints of the Vibration API rather than an implementation failure. The Vibration API only allows specifying vibration and pause durations (in ms) without control over intensity (amplitude). Therefore, runRamp creates a ramp-like effect by repeating short pulses with increasingly shorter pauses between them to change the perceived frequency.

As a result, instead of feeling like a smoothly increasing intensity, it feels more like pulses getting closer together. This behavior is consistent with the prompt's intention. However, it's difficult to express a smooth change using only timing patterns, and I felt the need for a mechanism that could specify intensity (amplitude).

Additionally, the interpretation of Vibration API patterns can vary across environments. It's easier to work with if you don't assume complex waveforms and limit it to short demonstration purposes.

About v0's Generation Quality

v0 reflected almost exactly the UI configuration specified in the prompt. It met requirements including status display, button disabling, implementation of Step/Ramp/Stop, and even the Test Tips text.

On the other hand, the dependencies can hardly be called minimal. It included shadcn/ui and the entire Radix UI suite, including libraries not directly related to this demo. This indicates that v0 generates based on a general-purpose UI template. Nevertheless, it's sufficient for the current purpose of "experiencing vibration on a physical device."

Summary

In this article, I generated a web app by providing a single prompt to v0, deployed it on Vercel, and confirmed the vibrations on Chrome on a Pixel 8 Pro. The Step function worked as intended. The Ramp felt more like pulses getting closer together rather than smoothly increasing in strength, but this was a natural result given the Vibration API's limitations on specifying intensity (amplitude).

Using v0 allowed me to generate both UI and implementation together. However, it tended to include many dependencies. While this might not be problematic for small demos, designs would need to be reviewed including dependencies if operational use is anticipated.

Share this article

FacebookHatena blogX

Related articles