Kinetik UI

Now Playing

.md

A dark music widget: the cover springs forward on play, the icon morphs and progress runs on the UI thread.

v1.0.0Live on webreact-native-reanimatedreact-native-svgreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add now-playing

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/now-playing.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-svg react-native-worklets
  2. Copy the source files
    • components/kinetik/now-playing.tsx
    View source files

Usage

import { NowPlaying } from '@/components/kinetik/now-playing';

<NowPlaying
  tracks={queue.map((t) => ({
    id: t.id,
    title: t.title,
    subtitle: t.artist,
    cover: { uri: t.artwork },
    duration: t.seconds,
  }))}
  onTrackChange={(t) => player.load(t.id)}
  onPlayingChange={(on) => (on ? player.play() : player.pause())}
/>;

When to use

  • A compact player for a queue: a widget, a mini player or a now-playing sheet header.

When not to use

  • As the source of truth for real audio: it keeps its own clock. Wire onPlayingChange and onTrackChange to your player, and keep the two in step.

Notes

  • Previous restarts the track if more than three seconds have played, as most players do; otherwise it goes back a track.
  • Tap the progress bar to seek. Screen readers adjust it in ten-second steps.
  • When a track ends, the next one starts. The queue wraps around.
  • clock(seconds) is exported for the same 1:09 format elsewhere.
  • The widget is dark on both themes. accent colours the progress and the play button.

Props

NowPlayingProps

PropTypeDefaultDescription
tracksrequiredreadonly NowPlayingTrack[]—
autoPlaybooleanfalseStart playing at once. Defaults to false.
onTrackChange(track: NowPlayingTrack) => void—Called with the track that starts.
onPlayingChange(playing: boolean) => void—
accentstring'hsl(28, 96%, 54%)'Colour of the progress and the play button. Defaults to amber orange.
hapticsbooleantrueA tick on each control. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Progress is one UI-thread timing per track; the time labels re-render four times a second while playing. Paused, nothing animates.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 174 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
Play, pause and skipping change state without the cover spring, slide or icon turn.
Screen readers
Title and subtitle read as one line; the progress is adjustable ("1:09 of 3:43", ten-second steps); Previous, Play/Pause and Next are buttons.
Touch target
Buttons are 44 pt tall; the progress bar's touch area is 44 pt tall.

Source files

components/kinetik/now-playing.tsx
import { useEffect, useRef, useState } from 'react';
import {
  Image,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  View,
  type GestureResponderEvent,
  type ImageSourcePropType,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  Easing,
  cancelAnimation,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type NowPlayingTrack = {
  id: string;
  title: string;
  /** Line under the title: the artist, or the piece. */
  subtitle: string;
  cover: ImageSourcePropType;
  /** Length in seconds. */
  duration: number;
};

export type NowPlayingProps = {
  tracks: readonly NowPlayingTrack[];
  /** Start playing at once. Defaults to false. */
  autoPlay?: boolean;
  /** Called with the track that starts. */
  onTrackChange?: (track: NowPlayingTrack) => void;
  onPlayingChange?: (playing: boolean) => void;
  /** Colour of the progress and the play button. Defaults to amber orange. */
  accent?: string;
  /** A tick on each control. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

// Player icons from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const ICONS = {
  play: 'M7 4v16l13 -8z',
  pause:
    'M6 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z M14 6a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z',
  next: 'M3 5v14l8 -7z M14 5v14l8 -7z',
  prev: 'M21 5v14l-8 -7z M10 5v14l-8 -7z',
};

const SERIF = Platform.select({ ios: 'Georgia', android: 'serif', default: 'Georgia, serif' });

/** "1:09" for elapsed time. */
export function clock(seconds: number, pad = false): string {
  const s = Math.max(0, Math.floor(seconds));
  const m = Math.floor(s / 60);
  return `${pad ? String(m).padStart(2, '0') : m}:${String(s % 60).padStart(2, '0')}`;
}

/**
 * Playback state for a single track: a 0–1 progress shared value that runs
 * on the UI thread while playing, and the elapsed seconds as text (updated a
 * few times a second, never per frame).
 */
function usePlayback(duration: number, playing: boolean, track: string, onEnd: () => void) {
  const progress = useSharedValue(0);
  const [elapsed, setElapsed] = useState(0);
  const [seeks, setSeeks] = useState(0);
  const ended = useRef(onEnd);
  useEffect(() => {
    ended.current = onEnd;
  });

  // A new track starts from the top: the readout resets during render, the bar in an effect.
  const [current, setCurrent] = useState(track);
  if (current !== track) {
    setCurrent(track);
    setElapsed(0);
  }
  useEffect(() => {
    cancelAnimation(progress);
    progress.set(0);
  }, [track, progress]);

  useEffect(() => {
    if (!playing || duration <= 0) {
      cancelAnimation(progress);
      return;
    }
    const from = progress.get();
    progress.set(
      withTiming(1, { duration: Math.max(0, (1 - from) * duration * 1000), easing: Easing.linear }),
    );
    const id = setInterval(() => {
      const p = progress.get();
      setElapsed(Math.floor(p * duration));
      if (p >= 1) ended.current();
    }, 250);
    return () => {
      clearInterval(id);
      cancelAnimation(progress);
    };
  }, [playing, duration, track, seeks, progress]);

  const seek = (share: number) => {
    const p = Math.max(0, Math.min(1, share));
    cancelAnimation(progress);
    progress.set(p);
    setElapsed(Math.floor(p * duration));
    setSeeks((n) => n + 1);
  };

  return { progress, elapsed, seek };
}

/**
 * A dark now-playing widget: the cover glows into the card, the title is set
 * in a serif, and the progress runs on the UI thread. Play springs the cover
 * forward and morphs the icon; skipping slides the next cover in.
 */
export function NowPlaying({
  tracks,
  autoPlay = false,
  onTrackChange,
  onPlayingChange,
  accent = 'hsl(28, 96%, 54%)',
  haptics = true,
  style,
}: NowPlayingProps) {
  const { radius, space, font } = useKinetikTheme();
  // A dark widget on either theme, like a lock-screen player.
  const colors = palettes.dark;
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [index, setIndex] = useState(0);
  const [playing, setPlaying] = useState(autoPlay);
  const [width, setWidth] = useState(0);
  const [barWidth, setBarWidth] = useState(0);
  const track = tracks[index];

  const go = (to: number) => {
    const next = (to + tracks.length) % tracks.length;
    setIndex(next);
    const t = tracks[next];
    if (t) onTrackChange?.(t);
  };
  const { progress, elapsed, seek } = usePlayback(
    track?.duration ?? 0,
    playing,
    track?.id ?? '',
    () => go(index + 1),
  );

  const lift = useSharedValue(playing ? 1 : 0);
  useEffect(() => {
    lift.set(reduced ? (playing ? 1 : 0) : withSpring(playing ? 1 : 0, spring('bouncy')));
  }, [playing, reduced, lift]);
  const swap = useSharedValue(1);
  useEffect(() => {
    if (reduced) return;
    swap.set(withSequence(withTiming(0, { duration: 0 }), withTiming(1, timing('slow'))));
  }, [index, reduced, swap]);

  const art = useAnimatedStyle(() => ({
    opacity: 0.35 + swap.get() * 0.65,
    transform: [
      { translateX: (1 - swap.get()) * 18 },
      { scale: (0.94 + lift.get() * 0.06) * (0.96 + swap.get() * 0.04) },
    ],
  }));
  const words = useAnimatedStyle(() => ({
    opacity: swap.get(),
    transform: [{ translateY: (1 - swap.get()) * 8 }],
  }));
  // A transform, not a width, so the bar never asks for layout while it plays.
  const fill = useAnimatedStyle(() => ({ transform: [{ scaleX: progress.get() }] }));

  if (!track) return null;
  const cover = width > 0 ? Math.min(150, width * 0.38) : 120;

  const toggle = () => {
    haptic('tap');
    setPlaying(!playing);
    onPlayingChange?.(!playing);
  };
  const back = () => {
    haptic('tick');
    if (elapsed > 3) seek(0);
    else go(index - 1);
  };
  const ahead = () => {
    haptic('tick');
    go(index + 1);
  };

  return (
    <View
      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
      style={[
        styles.card,
        {
          borderRadius: radius.xl + 4,
          padding: space[4],
          gap: space[5],
          borderColor: colors.border,
        },
        gradient(
          `linear-gradient(105deg, hsl(30, 32%, 20%) 0%, ${colors.background} 48%, ${colors.background} 100%)`,
        ),
        style,
      ]}
    >
      <Animated.View
        style={[
          styles.cover,
          {
            width: cover,
            height: cover,
            borderRadius: radius.xl - 6,
            boxShadow: '0px 10px 30px hsla(30, 40%, 30%, 0.45)',
          },
          art,
        ]}
      >
        <Image
          source={track.cover}
          style={StyleSheet.absoluteFill}
          resizeMode="cover"
          accessibilityIgnoresInvertColors
        />
      </Animated.View>

      <View style={[styles.grow, { gap: space[4] }]}>
        <Animated.View
          style={words}
          accessible
          accessibilityLabel={`${track.title}, ${track.subtitle}`}
        >
          <Text
            numberOfLines={1}
            style={{
              color: colors.highlight,
              fontFamily: SERIF,
              fontSize: font.size['2xl'],
              fontWeight: font.weight.bold,
            }}
          >
            {track.title}
          </Text>
          <Text numberOfLines={1} style={{ color: colors.textMuted, fontSize: font.size.sm + 1 }}>
            {track.subtitle}
          </Text>
        </Animated.View>

        <View style={{ gap: 8 }}>
          <Pressable
            accessibilityRole="adjustable"
            accessibilityLabel="Playback position"
            accessibilityValue={{
              min: 0,
              max: track.duration,
              now: elapsed,
              text: `${clock(elapsed)} of ${clock(track.duration)}`,
            }}
            accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
            onAccessibilityAction={(e) => {
              const step = 10 / track.duration;
              const now = elapsed / track.duration;
              seek(now + (e.nativeEvent.actionName === 'increment' ? step : -step));
            }}
            onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
            onPress={(e: GestureResponderEvent) =>
              barWidth > 0 && seek(e.nativeEvent.locationX / barWidth)
            }
            style={styles.barHit}
          >
            <View style={[styles.bar, { backgroundColor: withAlpha(colors.text, 0.16) }]}>
              <Animated.View style={[styles.barFill, { backgroundColor: accent }, fill]} />
            </View>
          </Pressable>
          <View style={styles.times}>
            <Text style={[styles.time, { color: colors.textMuted }]}>{clock(elapsed)}</Text>
            <Text style={[styles.time, { color: colors.textMuted }]}>
              -{clock(track.duration - elapsed, true)}
            </Text>
          </View>
        </View>

        <View style={[styles.controls, { gap: space[2] }]}>
          <Control label="Previous" icon={ICONS.prev} onPress={back} tone={colors.surfaceRaised} />
          <PlayButton playing={playing} onPress={toggle} tone={accent} />
          <Control label="Next" icon={ICONS.next} onPress={ahead} tone={colors.surfaceRaised} />
        </View>
      </View>
    </View>
  );
}

function Control({
  label,
  icon,
  onPress,
  tone,
}: {
  label: string;
  icon: string;
  onPress: () => void;
  tone: string;
}) {
  const ink = palettes.dark.highlight;
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={label}
      onPress={onPress}
      style={({ pressed }) => [
        styles.button,
        { backgroundColor: tone, transform: [{ scale: pressed ? 0.94 : 1 }] },
      ]}
    >
      <Svg width={18} height={18} viewBox="0 0 24 24">
        <Path d={icon} fill={ink} stroke={ink} strokeWidth={2} strokeLinejoin="round" />
      </Svg>
    </Pressable>
  );
}

/** Play and pause cross-fade and turn, so the icon seems to morph. */
function PlayButton({
  playing,
  onPress,
  tone,
}: {
  playing: boolean;
  onPress: () => void;
  tone: string;
}) {
  const reduced = useReduceMotion();
  const on = useSharedValue(playing ? 1 : 0);
  useEffect(() => {
    on.set(reduced ? (playing ? 1 : 0) : withSpring(playing ? 1 : 0, spring('snappy')));
  }, [playing, reduced, on]);
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={playing ? 'Pause' : 'Play'}
      onPress={onPress}
      style={({ pressed }) => [
        styles.button,
        { backgroundColor: tone, transform: [{ scale: pressed ? 0.94 : 1 }] },
      ]}
    >
      <Glyph icon={ICONS.play} on={on} show={0} />
      <Glyph icon={ICONS.pause} on={on} show={1} />
    </Pressable>
  );
}

function Glyph({ icon, on, show }: { icon: string; on: SharedValue<number>; show: 0 | 1 }) {
  const ink = palettes.dark.highlight;
  const style = useAnimatedStyle(() => {
    const v = show ? on.get() : 1 - on.get();
    return {
      opacity: v,
      transform: [{ scale: 0.6 + v * 0.4 }, { rotate: `${(1 - v) * (show ? -90 : 90)}deg` }],
    };
  });
  return (
    <Animated.View style={[styles.glyph, style]}>
      <Svg width={18} height={18} viewBox="0 0 24 24">
        <Path d={icon} fill={ink} stroke={ink} strokeWidth={2} strokeLinejoin="round" />
      </Svg>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, overflow: 'hidden' },
  cover: { overflow: 'hidden' },
  grow: { flex: 1, minWidth: 0 },
  // 44 pt to touch, with the extra height taken back so the bar keeps its place.
  barHit: { height: 44, marginVertical: -13, justifyContent: 'center' },
  bar: { height: 6, borderRadius: 3, overflow: 'hidden' },
  barFill: { height: 6, width: '100%', transformOrigin: 'left center' },
  times: { flexDirection: 'row', justifyContent: 'space-between' },
  time: { fontSize: 13, fontVariant: ['tabular-nums'] },
  controls: { flexDirection: 'row' },
  button: {
    flex: 1,
    height: 44,
    borderRadius: 12,
    alignItems: 'center',
    justifyContent: 'center',
  },
  glyph: { position: 'absolute' },
});