soundcloud-widget-react
v2.2.0
Published
React component for the SoundCloud HTML5 Widget API with full TypeScript support
Readme
soundcloud-widget-react
The definitive React wrapper for the SoundCloud HTML5 Widget API — TypeScript-native, Promise-based getters, reactive useSCWidget hook, SSR-safe, zero runtime dependencies.
Demo
Clone the repo and run the demo locally:
cd demo && pnpm install && pnpm devOr browse the demo source to see all features in action.
Why This Package?
The SoundCloud Widget API has been around for years, but most React wrappers for it were written before TypeScript was mainstream and before React hooks existed. The most popular alternative, react-soundcloud-widget, has been effectively abandoned — no TypeScript, no hooks, no Promise-based getters, no SSR guidance.
soundcloud-widget-react is a ground-up TypeScript rewrite built for the way React is written today:
- Full type coverage for the Widget API — events, params, payloads, all of it
- Promise-based getters —
getDurationAsync(),getPositionAsync(), etc. — instead of callback hell SCWidgetEventsenum so you never mistype an event name stringuseSCWidgethook for reactive state (isPlaying,positionMs,durationMs,sound) with zero boilerplate- SSR-safe with explicit Next.js patterns and tested duplicate-script injection prevention
"use client"directive shipped in the build — import directly from Next.js App Router components- Accessible iframe attributes (
title,loading,allow,sandbox,referrerPolicy) that older wrappers never exposed - Hidden iframe / controller-only mode for building fully custom audio UIs
- Zero runtime dependencies
- LLM-friendly — ships
llms.txt,llms-full.txt, andAGENTS.md
Comparison
| Feature | soundcloud-widget-react v2 | react-soundcloud-widget |
|---------|:-:|:-:|
| TypeScript | ✅ Full native | ⚠️ Partial / DefinitelyTyped |
| React version support | ✅ >= 17 | ⚠️ Old (no hook-era patterns) |
| Promise-based getters | ✅ Yes | ❌ No — callback only |
| SCWidgetEvents enum | ✅ Yes | ❌ No |
| Generic onEvent map | ✅ Yes | ❌ No |
| useSCWidget hook | ✅ Yes | ❌ No |
| Hidden / controller-only mode | ✅ Yes | ❌ No |
| Accessible iframe props | ✅ Yes | ❌ No |
| Next.js / SSR guidance | ✅ Yes | ❌ No |
| Duplicate script injection prevention | ✅ Yes | ❌ No |
| Maintained (2026) | ✅ Yes | ❌ No — last commit 2019 |
| Zero runtime dependencies | ✅ Yes | ✅ Yes |
Install
npm install soundcloud-widget-react
# or
pnpm add soundcloud-widget-react
# or
yarn add soundcloud-widget-reactPeer dependencies: react and react-dom >= 17.
React 17 note: the auto-generated iframe
idfalls back to a counter on React 17 (which has nouseId). That fallback is not SSR-stable — if you server-render with React 17, pass an explicitiframeId. React 18+ has no such caveat.
Quick Start
import { SCWidget } from "soundcloud-widget-react";
export default function App() {
return (
<SCWidget
trackId={308946187}
autoPlay={false}
showArtwork={true}
onReady={({ widget }) => console.log("Widget ready", widget)}
onPlay={(e) => console.log("Playing at", e.currentPosition)}
onFinish={() => console.log("Track finished")}
/>
);
}Prefer trackId / playlistId from soundcloud-api-ts (track.id or track.urn). That embeds the official Widget resource (https://api.soundcloud.com/tracks/{id}) — the same URL SoundCloud's oEmbed iframe uses. url still works for a permalink, an API resource, a full player iframe src, or a getSoundCloudWidgetUrl() fragment.
If none of trackId, playlistId, or url is set, the component renders nothing (so <SCWidget trackId={track?.id} /> can wait for data). Changing the source or any player param later does not reload the iframe; the component routes changes through widget.load().
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| trackId | string \| number | — | Track ID or soundcloud:tracks:{id} URN. Embeds https://api.soundcloud.com/tracks/{id} |
| playlistId | string \| number | — | Playlist ID or soundcloud:playlists:{id} URN. Embeds https://api.soundcloud.com/playlists/{id} |
| url | string | — | Permalink, API resource, player iframe src, or getSoundCloudWidgetUrl() fragment. Normalized automatically. If no source is set, nothing is rendered. |
| width | string \| number | "100%" | iframe width |
| height | string \| number | 166 | iframe height (ignored in hidden mode) |
| style | CSSProperties | — | Inline styles (ignored in hidden mode) |
| className | string | — | CSS class (ignored in hidden mode) |
| iframeId | string | auto-generated | id attribute for the iframe |
| title | string | — | Accessible title attribute for the iframe |
| loading | "eager" \| "lazy" | — | iframe loading attribute |
| allow | string | "autoplay" | iframe allow attribute |
| sandbox | string | — | iframe sandbox attribute |
| referrerPolicy | HTMLAttributeReferrerPolicy | — | iframe referrerpolicy |
| hidden | boolean | — | Render as invisible controller (1×1px, no UI) |
| autoPlay | boolean | — | Start playing automatically |
| color | string | — | Player color hex, e.g. "ff5500" |
| buying | boolean | — | Show buy button |
| sharing | boolean | — | Show share button |
| download | boolean | — | Show download button |
| showArtwork | boolean | — | Show artwork |
| showPlaycount | boolean | — | Show play count |
| showUser | boolean | — | Show user info |
| startTrack | number | — | Index of track to start on (playlists) |
| singleActive | boolean | — | Pause other widgets when this one plays |
| showTeaser | boolean | — | Show teaser * |
| visual | boolean | — | Enable visual (large artwork) mode * |
| liking | boolean | — | Show like button * |
| showComments | boolean | — | Show comments * |
| hideRelated | boolean | — | Hide related tracks * |
| onReady | (ctx: { widget: SCWidgetInstance }) => void | — | Fired when widget is ready. widget.load() accepts camelCase SCWidgetParams (translated to official snake_case). |
| onPlay | (e: SCAudioEventPayload) => void | — | Fired on play |
| onPause | (e: SCAudioEventPayload) => void | — | Fired on pause |
| onFinish | (e: SCAudioEventPayload) => void | — | Fired when track finishes |
| onSeek | (e: SCAudioEventPayload) => void | — | Fired on seek |
| onPlayProgress | (e: SCAudioEventPayload) => void | — | Fired during playback progress |
| onLoadProgress | (e: SCAudioEventPayload) => void | — | Fired during load progress |
| onError | () => void | — | Fired on player error |
| onClickDownload | () => void | — | Fired when download button clicked |
| onClickBuy | () => void | — | Fired when buy button clicked |
| onOpenSharePanel | () => void | — | Fired when share panel opens |
| onEvent | { [K in SCWidgetEvents]?: (payload: SCWidgetEventMap[K]) => void } | — | Generic per-event handlers (additive with named handlers) |
* showTeaser, visual, liking, showComments, and hideRelated are de-facto embed parameters used by SoundCloud's own embed builder; they are not listed in the official Widget API parameter docs, and SoundCloud may change them without notice.
SCAudioEventPayload
interface SCAudioEventPayload {
relativePosition: number; // 0–1
loadProgress: number; // 0–1
currentPosition: number; // milliseconds
}Ref Methods
Attach a ref to <SCWidget> for imperative control:
import { useRef } from "react";
import { SCWidget, SCWidgetRef } from "soundcloud-widget-react";
export default function Player() {
const playerRef = useRef<SCWidgetRef>(null);
return (
<>
<SCWidget ref={playerRef} url="https://soundcloud.com/artist/track" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={async () => {
const duration = await playerRef.current?.getDurationAsync() ?? 0;
console.log("Duration:", duration, "ms");
}}>
Log Duration
</button>
</>
);
}Imperative controls
| Method | Description |
|--------|-------------|
| play() | Start playback |
| pause() | Pause playback |
| toggle() | Toggle play/pause |
| seekTo(ms: number) | Seek to position in milliseconds |
| setVolume(volume: number) | Set volume (0–100, per the Widget API) |
| next() | Skip to next track (playlist) |
| prev() | Go to previous track (playlist) |
| skip(index: number) | Jump to track at index (playlist) |
| load(url, options?) | Load a new URL; camelCase SCWidgetParams are translated to official snake_case |
Callback-style getters
| Method | Description |
|--------|-------------|
| getDuration(cb) | Get track duration in ms |
| getPosition(cb) | Get current position in ms |
| getVolume(cb) | Get current volume (0–100) |
| getSounds(cb) | Get all sounds in playlist |
| getCurrentSound(cb) | Get currently playing sound (does not fire if the widget is not ready) |
| getCurrentSoundIndex(cb) | Get index of current sound |
| isPaused(cb) | Check if paused |
Promise-based getters (new in v2)
| Method | Returns | Description |
|--------|---------|-------------|
| getDurationAsync() | Promise<number> | Track duration in ms |
| getPositionAsync() | Promise<number> | Current position in ms |
| getVolumeAsync() | Promise<number> | Current volume (0–100) |
| getSoundsAsync() | Promise<SCSound[]> | All sounds in playlist |
| getCurrentSoundAsync() | Promise<SCSound> | Current sound (rejects if none) |
| getCurrentSoundIndexAsync() | Promise<number> | Index of current sound |
| isPausedAsync() | Promise<boolean> | Whether playback is paused |
SCWidgetEvents Enum
Use SCWidgetEvents for type-safe event names — no more string typos:
import { SCWidgetEvents } from "soundcloud-widget-react";
// SCWidgetEvents.READY = "ready"
// SCWidgetEvents.PLAY = "play"
// SCWidgetEvents.PAUSE = "pause"
// SCWidgetEvents.FINISH = "finish"
// SCWidgetEvents.SEEK = "seek"
// SCWidgetEvents.PLAY_PROGRESS = "play_progress"
// SCWidgetEvents.LOAD_PROGRESS = "load_progress"
// SCWidgetEvents.CLICK_BUY = "click_buy"
// SCWidgetEvents.CLICK_DOWNLOAD = "click_download"
// SCWidgetEvents.OPEN_SHARE_PANEL = "open_share_panel"
// SCWidgetEvents.ERROR = "error"Generic onEvent Binding
onEvent lets you handle multiple events in one map, with full type inference per event. Named props (onPlay, onPause, …) and onEvent entries are called independently — use both at once:
import { SCWidget, SCWidgetEvents } from "soundcloud-widget-react";
<SCWidget
url="https://soundcloud.com/artist/track"
onEvent={{
[SCWidgetEvents.READY]: () => console.log("ready"),
[SCWidgetEvents.PLAY]: (e) => console.log("play at", e.currentPosition),
[SCWidgetEvents.PLAY_PROGRESS]: (e) => console.log("progress", e.relativePosition),
[SCWidgetEvents.ERROR]: () => console.error("player error"),
}}
/>useSCWidget Hook
useSCWidget gives you reactive state and imperative controls without managing refs manually:
import { SCWidget, useSCWidget } from "soundcloud-widget-react";
export default function Player() {
const { ref, state, props, controls } = useSCWidget();
return (
<div>
<SCWidget
ref={ref}
url="https://soundcloud.com/artist/track"
{...props}
/>
<p>
{state.isPlaying ? "Playing" : "Paused"} —{" "}
{Math.round(state.positionMs / 1000)}s / {Math.round(state.durationMs / 1000)}s
</p>
{state.sound && <p>Now playing: {state.sound.title}</p>}
<button onClick={controls.play}>Play</button>
<button onClick={controls.pause}>Pause</button>
<button onClick={controls.toggle}>Toggle</button>
<button onClick={() => controls.seekTo(30_000)}>Seek to 0:30</button>
<button onClick={() => controls.setVolume(50)}>50% volume</button>
</div>
);
}The hook wires its own handlers through the named props (onReady, onPlay, onPause, onFinish, onSeek, onPlayProgress). If you need your own listeners on those events alongside the hook, register them via onEvent — named props and onEvent are additive.
SCWidgetState
interface SCWidgetState {
isReady: boolean; // true after onReady fires
isPlaying: boolean; // updated on play/pause/finish
positionMs: number; // updated on play_progress and seek
durationMs: number; // fetched on ready and on play (playlist next/prev)
sound: SCSound | null; // current track; refreshed on ready and play
soundIndex: number; // current index; refreshed on ready and play
}controls
| Method | Description |
|--------|-------------|
| play() | Start playback |
| pause() | Pause playback |
| toggle() | Toggle play/pause |
| seekTo(ms) | Seek to millisecond position |
| setVolume(v) | Set volume (0–100) |
| next() | Skip to next track |
| prev() | Go to previous track |
| skip(index) | Jump to track index |
| load(url, options?) | Load a new URL; camelCase SCWidgetParams are translated to snake_case |
Hidden iframe / Controller-Only Pattern
Render an invisible player to control audio without any visible UI — perfect for custom audio players, background music, or ambient audio:
import { SCWidget, useSCWidget } from "soundcloud-widget-react";
export default function InvisiblePlayer() {
const { ref, state, props, controls } = useSCWidget();
return (
<div>
{/* No visible player UI — audio only */}
<SCWidget
ref={ref}
url="https://soundcloud.com/artist/track"
hidden
autoPlay
{...props}
/>
<button onClick={controls.toggle}>
{state.isPlaying ? "Pause" : "Play"}
</button>
<span>{Math.round(state.positionMs / 1000)}s</span>
</div>
);
}The hidden prop renders the iframe as a 1×1 invisible element. The width, height, style, and className props are ignored in this mode.
SSR / Next.js
Rendering is SSR-safe (no window/document access during render — covered by tests), and the published files carry the "use client" directive, so you can import SCWidget directly from App Router code. The player itself only initializes in the browser — it requires window and communicates via postMessage.
Option A — Dynamic import
import dynamic from "next/dynamic";
const SCWidget = dynamic(
() => import("soundcloud-widget-react").then((m) => m.SCWidget),
{ ssr: false }
);Option B — next/script with beforeInteractive (recommended for persistent players)
Load the SoundCloud API in your root layout so it's available before React hydration. The library detects window.SC already present and skips duplicate script injection:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Script
strategy="beforeInteractive"
src="https://w.soundcloud.com/player/api.js"
/>
</body>
</html>
);
}// components/PlayerBar.tsx
"use client";
import { SCWidget } from "soundcloud-widget-react";
export function PlayerBar({ trackId }: { trackId: number }) {
return <SCWidget trackId={trackId} height={166} width="100%" autoPlay />;
}CSP configuration
Add these directives to your Content Security Policy:
frame-src https://w.soundcloud.com;
script-src https://w.soundcloud.com;Known Limitations
Instagram in-app browser — The Instagram browser blocks autoPlay and prevents play() calls that aren't triggered directly by a user gesture. Show a visible play button as a fallback:
const { ref, state, props, controls } = useSCWidget();
// Don't call play() on mount — wait for user interaction
return (
<>
<SCWidget ref={ref} url={url} {...props} hidden />
{!state.isPlaying && (
<button onClick={controls.play}>Tap to play</button>
)}
</>
);CSP — If your site has a restrictive CSP and the SoundCloud script fails to load, SCWidget will log the error to the console. Whitelist https://w.soundcloud.com in both frame-src and script-src.
Embed helpers
Use these when you are not rendering <SCWidget> (emails, MDX, a hand-built iframe):
import {
getOEmbed,
iframeSrcFromOEmbedHtml,
trackResourceUrl,
resolveWidgetResource,
} from "soundcloud-widget-react";
const resource = trackResourceUrl(308946187);
// https://api.soundcloud.com/tracks/308946187
const oembed = await getOEmbed("https://soundcloud.com/forss/flickermood");
const src = iframeSrcFromOEmbedHtml(oembed.html);<SCWidget trackId> already uses that same resource URL — you do not need getOEmbed for the React player.
Related
This package is part of the twin-paws SoundCloud ecosystem:
| Package | Purpose | |---------|---------| | soundcloud-api-ts | TypeScript-first SoundCloud REST API client — typed access to tracks, users, playlists, and OAuth | | soundcloud-api-ts-next | Next.js integration: React hooks, secure API routes, OAuth PKCE, RSC helpers | | soundcloud-widget-react ← you are here | React component for the SoundCloud HTML5 Widget API — embed players and control playback programmatically |
Common pattern — combine with the API packages in a Next.js app:
import { getTrack } from "soundcloud-api-ts-next/server";
import { SCWidget } from "soundcloud-widget-react";
const track = await getTrack(trackId, config, { revalidate: 60 });
<SCWidget trackId={track.id} onPlay={() => trackPlay(track.id)} />getOEmbed(url) is exported if you need the official iframe HTML string (emails, MDX). Helpers trackResourceUrl, playlistResourceUrl, resolveWidgetResource, normalizeWidgetUrl, embedId, and iframeSrcFromOEmbedHtml are also public. <SCWidget trackId> already uses the same resource URL without that extra request.
getSoundCloudWidgetUrl() from soundcloud-api-ts is a pre-encoded fragment for hand-built iframes. If you pass it as url, it is decoded automatically.
Contributing
Issues and PRs welcome at github.com/twin-paws/soundcloud-widget-react.
See CHANGELOG.md for version history.
License
MIT
