stream/ui
Browse the library

Comic Track List

A comic playlist with dashed ink dividers and the current track on halftone dots with a NOW! sticker.

betaComicTrackList.tsx

Examples

Basic

comic-track-list/basic.tsx
"use client";

import { useState } from "react";
import { ComicTrackList } from "@streamuilib/react";
import { useDemoTracks } from "@/lib/audio/demo-tracks";

export default function ComicTrackListBasic() {
  const [index, setIndex] = useState(0);
  return (
    <ComicTrackList
      tracks={useDemoTracks()}
      index={index}
      playing
      onSelect={setIndex}
      className="w-full max-w-sm -rotate-1 border-[3px] border-ink shadow-[5px_5px_0_var(--ink)]"
    />
  );
}

Installation

bun add @streamuilib/react
Open in v0 (opens in a new tab)
usage.tsx
import { ComicTrackList } from "@streamuilib/react";

<ComicTrackList tracks={tracks} index={index} playing onSelect={setIndex} />

API

PropTypeDefaultDescription
tracksTrack[]—The playlist.
indexnumber—The current track.
playingbooleanfalseBounces the current track's meter and shows NOW!
onSelect(index: number) => void—Called when a track is clicked.

Accessibility

  • An ordered list of buttons; the current one has aria-current and a hidden "Now playing" prefix, like TrackList.
  • The meter and sticker are decorative and hidden.

Source

packages/react/src/ComicTrackList.tsx
"use client";

import { cn } from "./cn";
import { halftone } from "./comic";
import { ComicEqualizer } from "./ComicEqualizer";
import { formatTime } from "./music";
import type { TrackListProps } from "./TrackList";

export type ComicTrackListProps = TrackListProps;

/**
 * A comic-book playlist: dashed ink dividers, the current track on halftone
 * dots with a NOW! sticker and a bouncing comic meter. Marked aria-current
 * and announced as now playing, the same as TrackList.
 */
export function ComicTrackList({ tracks, index, playing = false, onSelect, className, ...props }: ComicTrackListProps) {
  return (
    <ol aria-label="Playlist" className={cn("divide-y-[3px] divide-dashed divide-ink bg-paper-raised text-ink", className)} {...props}>
      {tracks.map((t, i) => {
        const current = i === index;
        return (
          <li key={t.id ?? `${i}-${t.title}`}>
            <button
              type="button"
              aria-current={current || undefined}
              onClick={() => onSelect?.(i)}
              className={cn(
                "sui-comic-track relative flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors duration-150 hover:bg-pink-soft focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-blue",
                current && "bg-lime-soft",
              )}
              style={current ? halftone(10) : undefined}
            >
              <span className="grid w-8 shrink-0 place-items-center font-mono text-sm font-bold tabular-nums">
                {current ? <ComicEqualizer playing={playing} bars={3} notes={false} className="h-5 gap-0.5 [&>span]:w-1.5 [&>span]:border-2" /> : String(i + 1).padStart(2, "0")}
              </span>
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="truncate font-bold">
                  {current && <span className="sr-only">{playing ? "Now playing: " : "Selected: "}</span>}
                  {t.title}
                </span>
                {t.artist && <span className="truncate text-xs text-ink-muted">{t.artist}</span>}
              </span>
              {current && playing && (
                <span
                  aria-hidden
                  className="sui-comic-stamp shrink-0 rotate-6 border-[3px] border-ink bg-pink px-1.5 py-0.5 font-mono text-[10px] font-bold uppercase text-on-bright shadow-[2px_2px_0_var(--ink)]"
                >
                  Now!
                </span>
              )}
              {t.duration != null && (
                <span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">{formatTime(t.duration)}</span>
              )}
            </button>
          </li>
        );
      })}
    </ol>
  );
}