Push to Talk New
Hold the mic or Space to talk, with a live waveform, timer and slide-to-cancel, while the transcript streams into the composer.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-push-to-talk.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/io/PushToTalk.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronLeft, Mic, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { mmss, prefersReducedMotion, rnd, useTicker } from './shared';
const PHRASE = 'Summarize the three customer calls from yesterday and draft a follow-up for Halcyon.';
export type PushToTalkProps = { onTranscript?: (text: string) => void; autoPlay?: boolean; className?: string };
/** Hold the mic (or Space) to talk; slide left to cancel. Transcription streams into the composer. */
export function PushToTalk({ onTranscript, autoPlay = false, className }: PushToTalkProps) {
const [recording, setRecording] = useState(false);
const [drag, setDrag] = useState(0);
const [text, setText] = useState('');
const [t, reset] = useTicker(recording, 24);
const startX = useRef(0);
const cancel = drag < -80;
const words = PHRASE.split(' ');
const live = recording ? words.slice(0, Math.min(words.length, Math.floor(t * 3.2))).join(' ') : '';
const begin = () => {
reset();
setDrag(0);
setRecording(true);
};
const end = (cancelled: boolean) => {
if (!recording) return;
setRecording(false);
setDrag(0);
if (!cancelled) {
const final = words.slice(0, Math.max(4, Math.floor(t * 3.2))).join(' ');
setText(final);
onTranscript?.(final);
}
};
// Demo: record a short take on mount so the component reads at a glance.
const endRef = useRef(end);
endRef.current = end;
useEffect(() => {
if (!autoPlay || prefersReducedMotion()) {
if (autoPlay) setText(PHRASE);
return;
}
const a = window.setTimeout(begin, 400);
const b = window.setTimeout(() => endRef.current(false), 4200);
return () => {
clearTimeout(a);
clearTimeout(b);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPlay]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.code !== 'Space' || e.repeat) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (!(e.target as HTMLElement)?.closest?.('[data-ptt]')) return;
e.preventDefault();
begin();
};
const up = (e: KeyboardEvent) => {
if (e.code === 'Space') end(false);
if (e.code === 'Escape') end(true);
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
});
return (
<div data-ptt className={cn('w-full max-w-xl rounded-2xl bg-ink-900 p-3 shadow-card', className)}>
<div className="min-h-[72px] rounded-xl bg-ink-950/60 px-3.5 py-3 text-[15px] leading-relaxed">
{recording ? (
<p className="text-ink-200">
{live}
<span className="ml-0.5 inline-block h-4 w-[2px] translate-y-[3px] animate-blink bg-brand-400" />
</p>
) : text ? (
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={2} aria-label="Message" className="w-full resize-none bg-transparent text-ink-100 outline-none" />
) : (
<p className="text-ink-500">Hold the mic or press and hold Space to talk…</p>
)}
</div>
<div className="mt-3 flex items-center gap-3">
{recording ? (
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="flex items-center gap-1.5 font-mono text-xs tabular-nums text-rose-300">
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-400" />
{mmss(t)}
</span>
<div className="flex h-8 min-w-0 flex-1 items-center gap-[3px] overflow-hidden" aria-hidden>
{Array.from({ length: 40 }, (_, i) => {
const v = prefersReducedMotion() ? 0.4 : 0.15 + Math.abs(Math.sin(t * 7 + i * 0.6)) * 0.6 * (0.5 + rnd(i + Math.floor(t * 8)) * 0.5);
return <span key={i} className={cn('w-[3px] shrink-0 rounded-full', cancel ? 'bg-ink-600' : 'bg-brand-400')} style={{ height: `${v * 100}%` }} />;
})}
</div>
<span className={cn('flex shrink-0 items-center gap-1 text-xs transition-colors', cancel ? 'text-rose-300' : 'text-ink-500')}>
{cancel ? <Trash2 className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{cancel ? 'Release to cancel' : 'Slide to cancel'}</span>
</span>
</div>
) : (
<div className="flex-1 text-xs text-ink-500">{text ? 'Edit the transcript, then send.' : 'Space to talk · Esc cancels'}</div>
)}
{text && !recording && (
<button onClick={() => setText('')} className="focus-ring flex h-10 w-10 items-center justify-center rounded-full bg-brand-500 text-ink-950 transition-transform active:scale-95" aria-label="Send">
<ArrowUp className="h-4 w-4" />
</button>
)}
<button
aria-label="Hold to talk"
aria-pressed={recording}
onPointerDown={(e) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startX.current = e.clientX;
begin();
}}
onPointerMove={(e) => recording && setDrag(Math.min(0, e.clientX - startX.current))}
onPointerUp={() => end(cancel)}
onPointerCancel={() => end(true)}
className={cn(
'focus-ring relative flex h-12 w-12 shrink-0 touch-none select-none items-center justify-center rounded-full transition-all duration-200',
recording ? (cancel ? 'scale-90 bg-rose-500/20 text-rose-300' : 'scale-110 bg-brand-500 text-ink-950 shadow-glow') : 'bg-white/5 text-ink-200 hover:bg-white/10'
)}
style={{ transform: recording ? `translateX(${Math.max(-120, drag)}px) scale(${cancel ? 0.9 : 1.1})` : undefined }}
>
{recording && !cancel && <span className="absolute inset-0 animate-pulse-ring rounded-full bg-brand-400" />}
<Mic className="relative h-5 w-5" />
</button>
</div>
</div>
);
}
export function PushToTalkDemo() {
return <PushToTalk autoPlay />;
}components/niceui/ai/io/shared.ts
import { useEffect, useRef, useState } from 'react';
export const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds elapsed while `active`, ticking at `fps`. */
export function useTicker(active: boolean, fps = 20) {
const [t, setT] = useState(0);
const base = useRef(0);
useEffect(() => {
if (!active) return;
const start = performance.now() - base.current * 1000;
const id = window.setInterval(() => {
base.current = (performance.now() - start) / 1000;
setT(base.current);
}, 1000 / fps);
return () => clearInterval(id);
}, [active, fps]);
const reset = () => {
base.current = 0;
setT(0);
};
return [t, reset] as const;
}
/** Deterministic pseudo-random 0..1. */
export const rnd = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;