Wave Timeline

A waveform player with timeline markers built on wavesurfer.js.

Loading...
import WaveTimeline from "@/components/wave-timeline";export default function AudioTimelineDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline src="/coastline.mp3" title="SoundHelix — Song 1" />    </div>  );}

Installation

Install the required shadcn/ui primitives.

npx shadcn@latest add button slider card skeleton

Add the component via jsrepo.

npx jsrepo add @waves-cn/ui/wave-timeline

This will automatically install wave-cn, wavesurfer.js and lucide-react.

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add button slider card skeleton

Copy wave-cn.tsx into your project.

/** * A React component for wavesurfer.js * * Usage: * * import WavesurferPlayer from '@wavesurfer/react' * * <WavesurferPlayer *   url="/my-server/audio.ogg" *   waveColor="purple" *   height={100} *   onReady={(wavesurfer) => console.log('Ready!', wavesurfer)} * /> */import {  useState,  useMemo,  useEffect,  useRef,  memo,  type ReactElement,  type RefObject,} from "react";import WaveSurfer, {  type WaveSurferEvents,  type WaveSurferOptions,} from "wavesurfer.js";type WavesurferEventHandler<T extends unknown[]> = (  wavesurfer: WaveSurfer,  ...args: T) => void;type OnWavesurferEvents = {  [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler<    WaveSurferEvents[K]  >;};type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;/** * Props for the Wavesurfer component * @public */export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };/** * Shared waveform defaults applied to every useWavesurfer instance. * Override any of these by passing the corresponding option explicitly. */export const WAVESURFER_DEFAULTS = {  waveColor: "var(--muted-foreground)",  progressColor: "var(--primary)",  height: 64,  barWidth: 3,  barGap: 2,  barRadius: 2,  minPxPerSec: 1,  cursorWidth: 0,} as const satisfies Partial<WaveSurferOptions>;/** * Use wavesurfer instance */function useWavesurferInstance(  containerRef: RefObject<HTMLDivElement | null>,  options: Partial<WaveSurferOptions>,): WaveSurfer | null {  const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);  // Flatten options object to an array of keys and values to compare them deeply in the hook deps  // Exclude plugins from deep comparison since they are mutated during initialization  const optionsWithoutPlugins = useMemo(() => {    // eslint-disable-next-line @typescript-eslint/no-unused-vars    const { plugins, ...rest } = options;    return rest;  }, [options]);  const flatOptions = useMemo(    () => Object.entries(optionsWithoutPlugins).flat(),    [optionsWithoutPlugins],  );  // Create a wavesurfer instance  useEffect(() => {    if (!containerRef?.current) return;    const ws = WaveSurfer.create({      ...optionsWithoutPlugins,      plugins: options.plugins,      container: containerRef.current,    });    setWavesurfer(ws);    return () => {      ws.destroy();    };    // Only recreate if plugins array reference changes (not on mutation)    // Users should memoize the plugins array to prevent unnecessary re-creation  }, [containerRef, options.plugins, ...flatOptions]);  return wavesurfer;}/** * Use wavesurfer state */function useWavesurferState(wavesurfer: WaveSurfer | null): {  isReady: boolean;  isPlaying: boolean;  currentTime: number;} {  const [isReady, setIsReady] = useState<boolean>(false);  const [isPlaying, setIsPlaying] = useState<boolean>(false);  const [hasFinished, setHasFinished] = useState<boolean>(false);  const [currentTime, setCurrentTime] = useState<number>(0);  useEffect(() => {    if (!wavesurfer) return;    const unsubscribeFns = [      wavesurfer.on("load", () => {        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);      }),      wavesurfer.on("ready", () => {        setIsReady(true);        setIsPlaying(false);        setHasFinished(false);        setCurrentTime(0);      }),      wavesurfer.on("finish", () => {        setHasFinished(true);      }),      wavesurfer.on("play", () => {        setIsPlaying(true);      }),      wavesurfer.on("pause", () => {        setIsPlaying(false);      }),      wavesurfer.on("timeupdate", () => {        setCurrentTime(wavesurfer.getCurrentTime());      }),      wavesurfer.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer]);  return useMemo(    () => ({      isReady,      isPlaying,      hasFinished,      currentTime,    }),    [isPlaying, hasFinished, currentTime, isReady],  );}const EVENT_PROP_RE = /^on([A-Z])/;const isEventProp = (key: string) => EVENT_PROP_RE.test(key);const getEventName = (key: string) =>  key.replace(EVENT_PROP_RE, (_, $1) =>    $1.toLowerCase(),  ) as keyof WaveSurferEvents;/** * Parse props into wavesurfer options and events */function useWavesurferProps(  props: WavesurferProps,): [PartialWavesurferOptions, OnWavesurferEvents] {  // Props starting with `on` are wavesurfer events, e.g. `onReady`  // The rest of the props are wavesurfer options  return useMemo<[PartialWavesurferOptions, OnWavesurferEvents]>(() => {    const allOptions = { ...props };    const allEvents = { ...props };    for (const key in allOptions) {      if (isEventProp(key)) {        delete allOptions[key as keyof WavesurferProps];      } else {        delete allEvents[key as keyof WavesurferProps];      }    }    return [allOptions, allEvents];  }, [props]);}/** * Subscribe to wavesurfer events */function useWavesurferEvents(  wavesurfer: WaveSurfer | null,  events: OnWavesurferEvents,) {  const flatEvents = useMemo(() => Object.entries(events).flat(), [events]);  // Subscribe to events  useEffect(() => {    if (!wavesurfer) return;    const eventEntries = Object.entries(events);    if (!eventEntries.length) return;    const unsubscribeFns = eventEntries.map(([name, handler]) => {      const event = getEventName(name);      return wavesurfer.on(event, (...args) =>        (handler as WavesurferEventHandler<WaveSurferEvents[typeof event]>)(          wavesurfer,          ...args,        ),      );    });    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer, ...flatEvents]);}/** * Wavesurfer player component * @see https://wavesurfer.xyz/docs/modules/wavesurfer * @public */const WavesurferPlayer = memo((props: WavesurferProps): ReactElement => {  const containerRef = useRef<HTMLDivElement | null>(null);  const { className, ...propsWithoutClassName } = props;  const [rawOptions, events] = useWavesurferProps(propsWithoutClassName);  // Apply WAVESURFER_DEFAULTS and resolve CSS var() tokens — same logic as  // useWavesurfer — so WavesurferPlayer benefits from shared defaults too.  const waveColor =    (rawOptions.waveColor as string | undefined) ??    WAVESURFER_DEFAULTS.waveColor;  const progressColor =    (rawOptions.progressColor as string | undefined) ??    WAVESURFER_DEFAULTS.progressColor;  const resolvedWaveColor = useCssVar(waveColor);  const resolvedProgressColor = useCssVar(progressColor);  const options = useMemo(() => {    const { waveColor: _wc, progressColor: _pc, ...rest } = rawOptions;    const cleanOptions = Object.fromEntries(      Object.entries(rest).filter(([, v]) => v !== undefined),    ) as PartialWavesurferOptions;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as PartialWavesurferOptions;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(rawOptions)]);  const wavesurfer = useWavesurferInstance(containerRef, options);  useWavesurferEvents(wavesurfer, events);  // Create a container div  return <div ref={containerRef} className={className} />;});/** * @public */export default WavesurferPlayer;/** * React hook for wavesurfer.js * * Automatically applies shared defaults (colors, height, bar shape, volume). * Any option passed explicitly will override the defaults. * CSS var() tokens in waveColor and progressColor are resolved at runtime * so they work correctly with the Canvas API. * * ``` * import { useWavesurfer } from '@/components/cors/wavesurfer-player' * * const App = () => { *   const containerRef = useRef<HTMLDivElement | null>(null) * *   const { wavesurfer, isReady, isPlaying, currentTime } = useWavesurfer({ *     container: containerRef, *     url: '/my-server/audio.ogg', *   }) * *   return <div ref={containerRef} /> * } * ``` * * @public */export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: Omit<WaveSurferOptions, "container"> & {  container: RefObject<HTMLDivElement | null>;}): ReturnType<typeof useWavesurferState> & {  wavesurfer: ReturnType<typeof useWavesurferInstance>;} {  // Resolve CSS var() tokens so the Canvas API receives actual color values  const resolvedWaveColor = useCssVar(waveColor as string);  const resolvedProgressColor = useCssVar(progressColor as string);  // Memoize mergedOptions so useWavesurferInstance gets a stable object reference.  // Without this, a new object is created every render → flatOptions changes →  // the instance is destroyed and recreated on every render, wiping spread props.  // Also strip undefined values so they don't silently override WAVESURFER_DEFAULTS.  const mergedOptions = useMemo(() => {    const cleanOptions = Object.fromEntries(      Object.entries(options).filter(([, v]) => v !== undefined),    ) as Partial<WaveSurferOptions>;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as Partial<WaveSurferOptions>;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(options)]);  const wavesurfer = useWavesurferInstance(container, mergedOptions);  const state = useWavesurferState(wavesurfer);  return useMemo(() => ({ ...state, wavesurfer }), [state, wavesurfer]);}export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(value);  useEffect(() => {    // Only resolve if the value looks like a CSS variable    const match = value.match(/^var\((--[^)]+)\)$/);    if (!match) {      setResolved(value);      return;    }    const varName = match[1];    const raw = getComputedStyle(document.documentElement)      .getPropertyValue(varName)      .trim();    // shadcn stores values as bare HSL channels e.g. "222.2 47.4% 11.2%"    // Canvas needs a full color string — wrap in hsl() if needed    const isHslChannels = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw);    setResolved(raw ? (isHslChannels ? `hsl(${raw})` : raw) : value);  }, [value]);  return resolved;}

Copy wave-timeline.tsx into your project.

import * as React from "react";import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline.esm.js";import { cn } from "@/lib/utils";import { Card, CardContent } from "@/components/ui/card";import { Button } from "@/components/ui/button";import { Slider } from "@/components/ui/slider";import {  Play,  Pause,  Volume2,  VolumeX,  Loader2,  RotateCcw,  ZoomIn,  ZoomOut,} from "lucide-react";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";// ─── Typesexport interface TimelineOptions {  height?: number;  timeInterval?: number;  primaryLabelInterval?: number;  secondaryLabelInterval?: number;  fontSize?: string;}export interface WaveTimelineProps {  src: string;  title?: string;  defaultVolume?: number;  waveColor?: string;  progressColor?: string;  barWidth?: number;  barGap?: number;  barRadius?: number;  waveHeight?: number;  defaultZoom?: number;  minZoom?: number;  maxZoom?: number;  topTimeline?: TimelineOptions | false;  bottomTimeline?: TimelineOptions | false;  onPlay?: () => void;  onPause?: () => void;  onFinish?: () => void;  onTimeUpdate?: (currentTime: number, duration: number) => void;  className?: string;}// ─── Helpersfunction formatTime(t: number): string {  const m = Math.floor(t / 60);  const s = Math.floor(t % 60);  return `${m}:${s.toString().padStart(2, "0")}`;}// ─── Componentexport function WaveTimeline({  src,  title,  defaultVolume = 0.8,  waveColor,  progressColor,  barWidth = 2,  barGap = 1,  barRadius = 2,  waveHeight = 80,  defaultZoom = 50,  minZoom = 10,  maxZoom = 500,  topTimeline = {},  bottomTimeline = false,  onPlay,  onPause,  onFinish,  onTimeUpdate,  className,}: WaveTimelineProps) {  const wavesurferRef = React.useRef<WaveSurfer | null>(null);  const [isReady, setIsReady] = React.useState(false);  const [isPlaying, setIsPlaying] = React.useState(false);  const [volume, setVolume] = React.useState(defaultVolume);  const [isMuted, setIsMuted] = React.useState(false);  const [duration, setDuration] = React.useState(0);  const [currentTime, setCurrentTime] = React.useState(0);  const [zoom, setZoom] = React.useState(defaultZoom);  // ── Memoized plugins ──────────────────────────────────────────────────────  // Stable array reference — WavesurferPlayer uses reference equality to decide  // whether to recreate the instance, so this must not change on every render.  const plugins = React.useMemo(() => {    if (typeof window === "undefined") return [];    const list: InstanceType<typeof TimelinePlugin>[] = [];    if (topTimeline !== false) {      list.push(        TimelinePlugin.create({          height: topTimeline.height ?? 20,          insertPosition: "beforebegin",          timeInterval: topTimeline.timeInterval ?? 0.5,          primaryLabelInterval: topTimeline.primaryLabelInterval ?? 5,          secondaryLabelInterval: topTimeline.secondaryLabelInterval ?? 1,          style: {            fontSize: topTimeline.fontSize ?? "11px",            color: "var(--muted-foreground)",            background: "var(--muted)",          },        }),      );    }    if (bottomTimeline !== false) {      list.push(        TimelinePlugin.create({          height: bottomTimeline.height ?? 14,          timeInterval: bottomTimeline.timeInterval ?? 0.1,          primaryLabelInterval: bottomTimeline.primaryLabelInterval ?? 1,          secondaryLabelInterval: bottomTimeline.secondaryLabelInterval,          style: {            fontSize: bottomTimeline.fontSize ?? "10px",            color: "var(--muted-foreground)",            background: "var(--muted)",          },        }),      );    }    return list;  }, [JSON.stringify(topTimeline), JSON.stringify(bottomTimeline)]);  // ── Event handlers  const handleReady = React.useCallback(    (ws: WaveSurfer) => {      wavesurferRef.current = ws;      ws.setVolume(defaultVolume);      setDuration(ws.getDuration());      setIsReady(true);    },    [defaultVolume],  );  const handlePlay = React.useCallback(() => {    setIsPlaying(true);    onPlay?.();  }, [onPlay]);  const handlePause = React.useCallback(() => {    setIsPlaying(false);    onPause?.();  }, [onPause]);  const handleFinish = React.useCallback(    (_ws: WaveSurfer) => {      setIsPlaying(false);      onFinish?.();    },    [onFinish],  );  const handleTimeupdate = React.useCallback(    (ws: WaveSurfer) => {      const t = ws.getCurrentTime();      setCurrentTime(t);      onTimeUpdate?.(t, ws.getDuration());    },    [onTimeUpdate],  );  const handleSeeking = React.useCallback((ws: WaveSurfer) => {    setCurrentTime(ws.getCurrentTime());  }, []);  const handleDestroy = React.useCallback(() => {    wavesurferRef.current = null;    setIsReady(false);    setIsPlaying(false);    setCurrentTime(0);    setDuration(0);  }, []);  // ── Zoom ──────────────────────────────────────────────────────────────────  const handleZoom = React.useCallback((v: number[]) => {    const value = v[0];    setZoom(value);    wavesurferRef.current?.zoom(value);  }, []);  const zoomIn = React.useCallback(() => {    const next = Math.min(zoom * 1.5, maxZoom);    setZoom(next);    wavesurferRef.current?.zoom(next);  }, [zoom, maxZoom]);  const zoomOut = React.useCallback(() => {    const next = Math.max(zoom / 1.5, minZoom);    setZoom(next);    wavesurferRef.current?.zoom(next);  }, [zoom, minZoom]);  // ── Playback ──────────────────────────────────────────────────────────────  const togglePlay = React.useCallback(    () => wavesurferRef.current?.playPause(),    [],  );  const restart = React.useCallback(() => {    if (!wavesurferRef.current || !isReady) return;    wavesurferRef.current.setTime(0);    wavesurferRef.current.play();  }, [isReady]);  const handleVolume = React.useCallback((v: number[]) => {    const value = v[0];    setVolume(value);    setIsMuted(value === 0);    wavesurferRef.current?.setVolume(value);  }, []);  const toggleMute = React.useCallback(() => {    if (!wavesurferRef.current) return;    const next = !isMuted;    setIsMuted(next);    wavesurferRef.current.setVolume(next ? 0 : volume);  }, [isMuted, volume]);  const handleSeek = React.useCallback(    ([v]: number[]) => {      if (!wavesurferRef.current || !isReady) return;      wavesurferRef.current.seekTo(v);    },    [isReady],  );  // ── Derived ───────────────────────────────────────────────────────────────  const progress = duration > 0 ? currentTime / duration : 0;  // ── Render ────────────────────────────────────────────────────────────────  return (    <Card      className={cn(        "w-full px-0 border-none rounded-none bg-transparent",        className,      )}    >      <CardContent className=" space-y-3 p-0">        {/* Title */}        {title && (          <p className="text-sm font-medium text-foreground truncate">            {title}          </p>        )}        {/* Waveform + timeline */}        <div className="relative w-full rounded-sm overflow-hidden border border-border">          {!isReady && (            <div              className="absolute inset-0 z-10 flex items-center justify-center bg-card/80 backdrop-blur-[2px]"              style={{ minHeight: waveHeight }}            >              <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />            </div>          )}          <WavesurferPlayer            url={src}            waveColor={waveColor}            progressColor={progressColor}            height={waveHeight}            barWidth={barWidth}            barGap={barGap}            barRadius={barRadius}            minPxPerSec={defaultZoom}            fillParent            dragToSeek            hideScrollbar={false}            plugins={plugins}            onReady={handleReady}            onPlay={handlePlay}            onPause={handlePause}            onFinish={handleFinish}            onTimeupdate={handleTimeupdate}            onSeeking={handleSeeking}            onDestroy={handleDestroy}          />        </div>        {/* Seek bar */}        <div className="flex items-center gap-2">          <span className="text-[11px] tabular-nums text-muted-foreground w-10 text-right shrink-0">            {formatTime(currentTime)}          </span>          <Slider            className="flex-1"            value={[progress]}            min={0}            max={1}            step={0.001}            disabled={!isReady}            onValueChange={handleSeek}          />          <span className="text-[11px] tabular-nums text-muted-foreground w-10 shrink-0">            {formatTime(duration)}          </span>        </div>        {/* Controls */}        <div className="flex items-center justify-between gap-3 flex-wrap">          {/* Playback */}          <div className="flex items-center gap-1.5">            <Button              size="icon"              variant="ghost"              className="h-8 w-8 text-muted-foreground hover:text-foreground"              disabled={!isReady}              onClick={restart}              aria-label="Restart"            >              <RotateCcw size={15} />            </Button>            <Button              size="icon"              variant="secondary"              className="h-9 w-9"              disabled={!isReady}              onClick={togglePlay}              aria-label={isPlaying ? "Pause" : "Play"}            >              {isPlaying ? <Pause size={17} /> : <Play size={17} />}            </Button>          </div>          {/* Zoom */}          <div className="flex items-center gap-2 flex-1 max-w-50">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              disabled={!isReady || zoom <= minZoom}              onClick={zoomOut}              aria-label="Zoom out"            >              <ZoomOut size={15} />            </Button>            <Slider              value={[zoom]}              min={minZoom}              max={maxZoom}              step={1}              disabled={!isReady}              onValueChange={handleZoom}              aria-label="Zoom"            />            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              disabled={!isReady || zoom >= maxZoom}              onClick={zoomIn}              aria-label="Zoom in"            >              <ZoomIn size={15} />            </Button>          </div>          {/* Volume */}          <div className="flex items-center gap-2 w-32">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              onClick={toggleMute}              aria-label={isMuted ? "Unmute" : "Mute"}            >              {isMuted ? <VolumeX size={15} /> : <Volume2 size={15} />}            </Button>            <Slider              value={[isMuted ? 0 : volume]}              min={0}              max={1}              step={0.01}              onValueChange={handleVolume}              aria-label="Volume"            />          </div>        </div>      </CardContent>    </Card>  );}export default WaveTimeline;

Update the import paths to match your project setup.

Features

  • Timeline ruler — tick marks and time labels rendered above and/or below the waveform via the WaveSurfer Timeline plugin; fully themed via useCssVar.
  • Zoom — three-way zoom control: step buttons (×1.5) and a precise slider, all calling ws.zoom() at runtime.
  • Seek bar — a shadcn Slider synced to playback progress; click or drag the waveform directly thanks to dragToSeek.
  • Volume & mute — slider-based volume with a mute toggle that preserves the last set level.
  • Loading state — a spinner overlays the waveform while audio is being fetched and decoded.
  • Shadcn themingwaveColor and progressColor accept any CSS value including var(--*) tokens, resolved at runtime for the Canvas API via useCssVar.

Usage

import { AudioTimeline } from "@/components/ui/wave-timeline"
<AudioTimeline url="/coastline.mp3" />

Examples

With dual timelines

Show both a top and bottom timeline ruler.

Loading...
import WaveTimeline from "@/components/wave-timeline";export default function AudioTimelineDualDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline        src="/coastline.mp3"        title="Dual Timeline"        topTimeline={{ height: 20, primaryLabelInterval: 5, timeInterval: 0.5 }}        bottomTimeline={{          height: 14,          primaryLabelInterval: 1,          timeInterval: 0.1,        }}      />    </div>  );}

No ruler

Pass topTimeline={false} to render the waveform and zoom controls without any timeline.

Loading...
import WaveTimeline from "@/components/wave-timeline";export default function AudioTimelineNoRulerExample() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline src="/coastline.mp3" title="No Ruler" topTimeline={false} />    </div>  );}

Custom zoom range

<AudioTimeline
  url="/coastline.mp3"
  defaultZoom={100}
  minZoom={20}
  maxZoom={1000}
/>

API Reference

AudioTimeline

PropTypeDefaultDescription
urlstring(Required) Audio file URL to load.
waveColorstringvar(--muted-foreground)Audio bar color. Accepts any CSS value including var(--*) tokens.
progressColorstringvar(--primary)Progress bar color. Accepts any CSS value including var(--*) tokens.
waveHeightnumber80Audio canvas height in px.
barWidthnumber2Bar width in px.
barGapnumber1Gap between bars in px.
barRadiusnumber2Bar border radius in px.
defaultZoomnumber50Initial zoom level in pixels per second.
minZoomnumber10Minimum zoom level.
maxZoomnumber500Maximum zoom level.
topTimelineTimelineOptions | false{}Top timeline config. Pass false to hide.
bottomTimelineTimelineOptions | falsefalseBottom timeline config. Pass {} or options to show.
classNamestringRoot element class.
styleCSSPropertiesRoot element inline style.

TimelineOptions

OptionTypeDefault (top)Default (bottom)Description
heightnumber2014Height of the timeline bar in px.
timeIntervalnumber0.50.1Seconds between each tick mark.
primaryLabelIntervalnumber51Seconds between labeled ticks.
secondaryLabelIntervalnumber1undefinedSeconds between secondary labeled ticks.
fontSizestring"11px""10px"CSS font size for labels.

On this page