opensourceui
GitHubTwitter/X
  1. Home
  2. Components
  3. Audio
  4. Recorder Face
Back to Audio

Components / Audio

Recorder Face

Free Audio React component — RecorderFaceWidget. MIT licensed, copy-paste ready for Next.js and Tailwind CSS.

A quirky record button with a mascot face, circular dial, and live timer. Tap to start and pause — feels like a tiny dedicated recorder app.

Preview

Setup

How to use

Free for personal and commercial use. No UI attribution required. Include the MIT copyright notice when copying component source into your project. Read the MIT license.

  1. 1

    Run in your terminal:

    $npm install clsx tailwind-merge lucide-react
  2. 2

    Copy lib/cn.ts below. Skip if you already have cn().

  3. 3

    Copy the code below and create components/audio/recorder-face-widget.tsx in your project.

  4. 4

    Import and render:

    Example

    import { RecorderFaceWidget } from "@/components/audio/recorder-face-widget";

    <RecorderFaceWidget />

lib/cn.ts

1
2
3
4
5
6
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

components/audio/recorder-face-widget.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"use client";
 
import {
forwardRef,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type MouseEvent,
} from "react";
 
import { cn } from "@/lib/cn";
import { Pause, Play } from "lucide-react";
 
const ACCENT = "#FF453A";
const MASCOT = "#6CA8FF";
 
// Formats elapsed milliseconds as MM:SS for the dial readout
function formatTime(ms: number) {
const s = Math.floor(ms / 1000);
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
 
type RecordMascotProps = Readonly<{
recording: boolean;
}>;
 
// Blue mascot at the bottom — mouth opens into an O while recording
function RecordMascot({ recording }: RecordMascotProps) {
return (
<g aria-hidden>
<circle cx="88" cy="142" r="58" fill={MASCOT} />
<circle cx="68" cy="122" r="5" fill="white" />
<circle cx="108" cy="122" r="5" fill="white" />
<circle cx="69" cy="123" r="2.2" fill="#1C1C1E" />
<circle cx="109" cy="123" r="2.2" fill="#1C1C1E" />
{recording ? (
<ellipse cx="88" cy="139" rx="5" ry="4" fill="white" />
) : (
<path
d="M76 138 Q88 146 100 138"
stroke="white"
strokeWidth="2.2"
fill="none"
strokeLinecap="round"
/>
)}
</g>
);
}
 
export type RecorderFaceWidgetProps = Readonly<
ComponentPropsWithoutRef<"button">
>;
 
// Recorder widget — black dial, mascot face, play/pause icon, live timer
export const RecorderFaceWidget = forwardRef<
HTMLButtonElement,
RecorderFaceWidgetProps
>(({ className, onClick, ...props }, ref) => {
const [recording, setRecording] = useState(false);
const [elapsed, setElapsed] = useState(0);
// startRef = when the current run began; baseRef = ms already counted before pause
const startRef = useRef(0);
const baseRef = useRef(0);
 
// requestAnimationFrame loop keeps the timer smooth while recording
useEffect(() => {
if (recording) {
startRef.current = performance.now();
let frame = 0;
const tick = () => {
setElapsed(baseRef.current + performance.now() - startRef.current);
frame = globalThis.requestAnimationFrame(tick);
};
frame = globalThis.requestAnimationFrame(tick);
return () => globalThis.cancelAnimationFrame(frame);
}
}, [recording]);
 
const toggle = (e: MouseEvent<HTMLButtonElement>) => {
if (recording) {
baseRef.current += performance.now() - startRef.current;
setElapsed(baseRef.current);
setRecording(false);
} else {
baseRef.current = 0;
setElapsed(0);
setRecording(true);
}
onClick?.(e);
};
 
return (
<button
ref={ref}
type="button"
onClick={toggle}
aria-pressed={recording}
aria-label={recording ? "Pause recording" : "Start recording"}
data-slot="minimal-record-button"
className={cn(
"relative h-44 w-44 max-w-full cursor-pointer overflow-hidden rounded-[1.75rem] bg-black font-sans shadow-lg shadow-black/5 select-none",
className,
)}
{...props}
>
<svg
viewBox="0 0 176 176"
className="absolute inset-0 h-full w-full"
aria-hidden
>
<rect width="176" height="176" fill="#000" />
<RecordMascot recording={recording} />
<circle
cx="88"
cy="84"
r="56"
fill="none"
stroke="white"
strokeWidth="1.5"
opacity="0.35"
/>
</svg>
 
<span className="absolute top-3.5 right-3.5 z-10" aria-hidden>
{recording ? (
<Pause size={14} color={ACCENT} fill={ACCENT} />
) : (
<Play size={14} color="white" fill="white" />
)}
</span>
 
<p
className={cn(
"relative z-10 flex h-full items-center justify-center pb-10 text-[26px] leading-none font-extralight tracking-[-0.04em] tabular-nums",
recording ? "text-white" : "text-white/40",
)}
>
{formatTime(elapsed)}
</p>
</button>
);
});
 
RecorderFaceWidget.displayName = "RecorderFaceWidget";
PreviousNext

On this

page

Jump to a section on this page.

  • lib/cn.ts
  • components/audio/recorder-face-widget.tsx

Sponsor spot · demo preview

Logo

Your brand name

Your tagline or category

A short pitch about your product or service goes here. This is a preview of how sponsor cards will look in this sidebar.

Your call to action

This slot is available. Sponsor Opensource UI and reach developers browsing components every day.