# Audio Player

> Audio player UI with cover art, seek bar, previous/next, volume and mute (simulated playback).

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/audio-player
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/audio-player.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/audio-player-demo.tsx"
"use client"

import { AudioPlayer, type Track } from "@/components/audio-player"

const tracks: Track[] = [
  { id: "1", title: "Midnight Drive", artist: "Neon Harbor", duration: 214, cover: "linear-gradient(135deg, #1e1b4b, #7c3aed 55%, #f472b6)" },
  { id: "2", title: "Slow Tide", artist: "Paper Lanterns", duration: 187, cover: "linear-gradient(135deg, #0f766e, #38bdf8)" },
  { id: "3", title: "Golden Hour", artist: "Aya Mori", duration: 241, cover: "linear-gradient(135deg, #f59e0b, #ef4444 60%, #7f1d1d)" },
]

export default function AudioPlayerDemo() {
  return <AudioPlayer tracks={tracks} />
}
```

## Source

```tsx title="components/audio-player.tsx"
"use client"

import * as React from "react"
import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react"

import { cn } from "@/lib/utils"

export interface Track {
  id: string
  title: string
  artist: string
  /** Seconds */
  duration: number
  /** Any CSS gradient used as cover art */
  cover: string
}

export interface AudioPlayerProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  tracks: Track[]
  defaultIndex?: number
}

const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`

/** A UI-only player: it simulates playback so the component needs no audio files. */
export function AudioPlayer({ tracks, defaultIndex = 0, className, ...props }: AudioPlayerProps) {
  const [{ index, position }, setPlayer] = React.useState({ index: defaultIndex, position: 0 })
  const [playing, setPlaying] = React.useState(false)
  const [volume, setVolume] = React.useState(70)
  const track = tracks[index]

  const wrap = (to: number) => ((to % tracks.length) + tracks.length) % tracks.length
  const go = (to: number) => setPlayer({ index: wrap(to), position: 0 })
  const setPosition = (position: number) => setPlayer((s) => ({ ...s, position }))

  React.useEffect(() => {
    if (!playing) return
    const timer = window.setInterval(() => {
      setPlayer((s) => {
        const next = s.position + 0.25
        return next >= tracks[s.index].duration ? { index: (s.index + 1) % tracks.length, position: 0 } : { ...s, position: next }
      })
    }, 250)
    return () => window.clearInterval(timer)
  }, [playing, tracks])

  const ghost = "flex size-10 items-center justify-center rounded-full text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40"

  return (
    <div {...props} className={cn("w-full max-w-sm rounded-3xl border bg-card p-5 shadow-sm", className)}>
      <div className="relative aspect-square w-full overflow-hidden rounded-2xl" style={{ backgroundImage: track.cover }}>
        <div
          aria-hidden
          className={cn("absolute inset-0 bg-black/10 transition-opacity duration-500", playing ? "opacity-0" : "opacity-100")}
        />
      </div>

      <div className="mt-4 text-center leading-tight" aria-live="polite">
        <h3 className="truncate text-lg font-semibold">{track.title}</h3>
        <p className="truncate text-sm text-muted-foreground">{track.artist}</p>
      </div>

      <div className="mt-4">
        <input
          type="range"
          aria-label="Seek"
          min={0}
          max={track.duration}
          step={1}
          value={Math.min(position, track.duration)}
          onChange={(event) => setPosition(Number(event.target.value))}
          className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
          style={{ background: `linear-gradient(to right, var(--primary) ${(Math.min(position, track.duration) / track.duration) * 100}%, var(--muted) 0)` }}
        />
        <div className="mt-1 flex justify-between text-xs tabular-nums text-muted-foreground">
          <span data-testid="elapsed">{clock(position)}</span>
          <span>-{clock(Math.max(0, track.duration - position))}</span>
        </div>
      </div>

      <div className="mt-2 flex items-center justify-center gap-3">
        <button type="button" aria-label="Previous track" onClick={() => (position > 3 ? setPosition(0) : go(index - 1))} className={ghost}>
          <SkipBack className="size-5 fill-current" />
        </button>
        <button
          type="button"
          aria-label={playing ? "Pause" : "Play"}
          onClick={() => setPlaying(!playing)}
          className="flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md outline-none transition-transform hover:scale-105 focus-visible:ring-4 focus-visible:ring-ring/40 active:scale-95"
        >
          {playing ? <Pause className="size-6 fill-current" /> : <Play className="size-6 translate-x-0.5 fill-current" />}
        </button>
        <button type="button" aria-label="Next track" onClick={() => go(index + 1)} className={ghost}>
          <SkipForward className="size-5 fill-current" />
        </button>
      </div>

      <div className="mt-4 flex items-center gap-3">
        <button
          type="button"
          aria-label={volume === 0 ? "Unmute" : "Mute"}
          onClick={() => setVolume(volume === 0 ? 70 : 0)}
          className="rounded text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          {volume === 0 ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
        </button>
        <input
          type="range"
          aria-label="Volume"
          min={0}
          max={100}
          value={volume}
          onChange={(event) => setVolume(Number(event.target.value))}
          className="h-1 flex-1 cursor-pointer appearance-none rounded-full accent-primary"
          style={{ background: `linear-gradient(to right, var(--primary) ${volume}%, var(--muted) 0)` }}
        />
      </div>
    </div>
  )
}
```
