> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cskn.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# TV kit

> Build a 10-foot TV surface: the app frame, hero, shelves, grids, an audio player and a vertical clip feed, all driven with a remote.

A TV is watched from across the room and driven with a remote. The TV kit gives your app the shape TV interfaces share: a rail of section icons on the left, big artwork, shelves of cards that scroll with focus, and a dark theme. Everything is sized from the screen's width, so a 1920-wide webOS page and a 960-wide Android TV WebView draw the same picture. The content stays inside the TV's safe area, 5% in from every edge, because some sets crop it.

Use it for the `tv` entry of [`appSurfaces`](https://docs.cskn.dev/concepts/surfaces#one-ui-per-surface), and list `tv` in your `cskn.json` [`platforms`](https://docs.cskn.dev/concepts/surfaces#choosing-where-your-app-is-listed). Every kit component is focusable and works with [TV navigation](https://docs.cskn.dev/ui/tv-navigation.md).

```tsx theme={null}
import { TvApp, TvScreen, TvHero, TvRow, TvCard, TvButton } from '@cskn/ui';

export default function TvSurface() {
  const [section, setSection] = useState('home');
  const [open, setOpen] = useState<string | null>(null);

  return (
    <TvApp
      nav={[
        { key: 'home', label: 'Home', icon: 'home' },
        { key: 'library', label: 'Library', icon: 'bookmark' },
      ]}
      active={section}
      onNavigate={setSection}
      exitLabel="Exit"
    >
      {open ? (
        <TvScreen id={`show-${open}`} onBack={() => setOpen(null)}>
          <ShowDetail id={open} />
        </TvScreen>
      ) : (
        <TvScreen id="home">
          <TvHero
            title={featured.title}
            backdrop={featured.backdrop}
            meta="2026 · Documentary · 48 min"
            description={featured.summary}
            actions={<TvButton label="Play" icon="play" variant="primary" autoFocus onPress={() => play(featured)} />}
          />
          <TvRow title="Continue watching">
            {inProgress.map((v) => (
              <TvCard key={v.id} focusKey={v.id} title={v.title} image={v.thumb} progress={v.progress} onPress={() => setOpen(v.id)} />
            ))}
          </TvRow>
        </TvScreen>
      )}
    </TvApp>
  );
}
```

## TvApp

The app's frame. The rail opens over the content when the remote's focus reaches it; the content doesn't move. Back from the content goes to the rail, and Back on the rail leaves the app. Screens and overlays handle Back before either.

**nav** `\{ key, label, icon \}[]` *required*

The app's sections, down the rail.

**active** `string` *required*

The section showing.

**onNavigate** `(key: string) =\> void` *required*

A section was chosen.

**children** `ReactNode` *required*

The screen: a `TvScreen` with heroes, rows and grids.

**logo** `ReactNode`

Your app's mark at the top of the rail.

**exitLabel** `string`

The rail's last item, back to the TV shell. Left out when there is no shell.

**bottomBar** `ReactNode`

A bar along the bottom, such as a media app's `TvPlayerBar`. The content gets room under it, and Down reaches it. Pass nothing while there's nothing to show.

The theme is dark. Override the `--cskn-tv-*` CSS variables, such as `--cskn-tv-bg`, to recolor it.

## TvScreen

One screen of the app. When you arrive, focus goes back to where it was when you last left this screen (the card you opened), or else to the item marked `autoFocus`, or to the first item.

**id** `string` *required*

Names the screen, so returning to it restores focus.

**onBack** `() =\> void`

Back on this screen, for example from a detail page to the list.

Give cards and list items a `focusKey` so the remembered focus survives the data reloading; otherwise their position is what's remembered.

## Content

| Component | What it is |
| - | - |
| `TvHero` | A wide backdrop faded into the page, with a title or logo, a meta line, a description and buttons. `compact` makes it shorter, for a detail page. |
| `TvRow` | A titled shelf of cards side by side that scrolls with focus. |
| `TvGrid` | Cards in lines. `columns` defaults to 5; landscape cards read best at 4 and posters at 6. |
| `TvCard` | Artwork with a title. Shapes: `landscape` (16:9, video), `poster` (2:3, films), `square` (albums, podcasts), `circle` (people). |
| `TvList` / `TvListItem` | A vertical list with square art, for episodes and tracks. |
| `TvButton` | A remote-sized button. `variant="primary"` for the main action; `iconOnly` for a round icon button. |
| `TvEmpty` | `title`, `hint?` and `action?` for an empty section. |

**TvCard**: `title`, `onPress` (required), `subtitle?`, `image?`, `shape?` (default `landscape`), `width?` (in a row, in units of a 1920-wide screen), `badge?` (over a corner, such as "LIVE"), `progress?` (0–1, a bar along the bottom), `focusKey?`, `autoFocus?`.

**TvHero**: `title` (required), `backdrop?`, `logo?` (a wordmark in place of the title), `meta?`, `description?`, `actions?` (usually `TvButton`s, the first with `autoFocus`), `compact?`.

**TvListItem**: `title`, `onPress` (required), `subtitle?`, `image?`, `meta?` (a duration or date), `playing?`, `focusKey?`, `autoFocus?`.

**TvButton**: `label`, `onPress` (required), `icon?`, `variant?`, `iconOnly?` (`label` becomes its accessible name), `autoFocus?`, `disabled?`.

## Audio player

For music and podcast apps: a full-screen player and a compact bar that opens it. Your app owns the audio element; these draw the controls.

```tsx theme={null}
import { TvApp, TvAudioPlayer, TvPlayerBar, useTvMediaKeys } from '@cskn/ui';

const [expanded, setExpanded] = useState(false);
const controls = {
  title: episode.title, subtitle: show.name, artwork: show.cover,
  playing, position, duration,
  onPlayPause: toggle, onSeekBy: seekBy,
  onNext: next, hasNext: !!queue.length,
};

useTvMediaKeys({ onPlayPause: toggle, onForward: () => seekBy(30), onRewind: () => seekBy(-15), onNext: next });

<TvApp {...frame} bottomBar={episode && <TvPlayerBar {...controls} onExpand={() => setExpanded(true)} expandLabel="Open the player" />}>
  {screen}
  <TvAudioPlayer {...controls} open={expanded} onClose={() => setExpanded(false)} kicker="Now playing" />
</TvApp>
```

`TvAudioPlayer` fills the screen with the artwork over a darkened wash of its colors. Back closes it and the audio keeps playing.

**open / onClose** `boolean / () =\> void` *required*

Visibility. Back calls `onClose`.

**title** `string` *required*

What's playing.

**playing, position, duration** `boolean, number, number` *required*

Playback state. Times in seconds; `duration` is 0 while unknown.

**onPlayPause** `() =\> void` *required*

Toggle playback.

**onSeekBy** `(seconds: number) =\> void`

Jump by seconds (negative is back). Without it, there are no skip buttons.

**skipBack / skipForward** `number` (default: 15 / 30)

Seconds the skip buttons jump.

**onPrevious / onNext, hasPrevious / hasNext** `() =\> void, boolean`

Previous and next buttons. Without the handlers, there are no such buttons.

**subtitle, kicker, artwork** `string`

The line under the title, the small line above it, and square art.

**actions** `ReactNode`

More round buttons after the transport (speed, like, queue): `TvButton`s with `iconOnly`.

**children** `ReactNode`

Under the transport: up next (a `TvList`), show notes.

**labels** `object`

Accessible names: `play`, `pause`, `previous`, `next`, `skipBack`, `skipForward`.

`TvPlayerBar` takes the same playback props plus `onExpand` and `expandLabel`; its first button opens the full player. Put it in `TvApp`'s `bottomBar`.

`useTvMediaKeys(handlers, enabled?)` handles the remote's media keys wherever focus is: `onPlayPause`, `onPlay`, `onPause`, `onForward`, `onRewind`, `onNext`, `onPrevious`, `onStop`. A key without a handler is left alone, and Play or Pause with only `onPlayPause` toggles.

`useTvOverlayHost()` returns the element full-screen layers like `TvAudioPlayer` render into, for your own overlays.

## Clip feed

Short vertical videos, one at a time: the clip at full height in the middle over a blurred wash of itself. Up and Down change the clip, Left and Right change the feed, OK pauses, Back leaves the app. Every arrow already has a job, so use `TvClipFeed` instead of `TvApp`, not inside it.

```tsx theme={null}
import { TvClipFeed } from '@cskn/ui';

<TvClipFeed
  feeds={[{ key: 'following', label: 'Following' }, { key: 'explore', label: 'Explore' }]}
  feed={feed}
  onFeedChange={setFeed}
  clips={clips}
  loading={isLoading}
  empty={{ title: 'Nothing here yet', hint: 'Follow someone to see their clips' }}
  onEndReached={() => fetchNextPage()}
  onClipChange={(clip) => reportView(clip.key)}
  renderPlayer={(clip, { playing }) => <ClipVideo src={clip.src} playing={playing} />}
/>
```

**feeds** `\{ key, label \}[]` *required*

The feeds named along the top.

**feed / onFeedChange** `string / (key) =\> void` *required*

The feed showing. Each feed keeps its place, so switching away and back returns to the same clip.

**clips** `TvClip[]` *required*

The current feed's clips. Each has `key`, `title` (who posted it), and optionally `subtitle`, `avatar`, `description`, `poster` and `stats` (`{ icon, label, value }[]`).

**renderPlayer** `(clip, \{ playing \}) =\> ReactNode` *required*

The media for the clip on screen. Only one is ever mounted, because a TV has one or two hardware decoders; the clips on either side show their posters. A `<video>` placed directly here is sized by the feed.

**loading** `boolean`

Drawn only while there are no clips, so a reload keeps the current clip on screen.

**empty / onEmptyPress** `\{ title, hint? \} / () =\> void`

The empty state, and OK on it (try again).

**onEndReached** `() =\> void`

Near the last clip: load the next page.

**onClipChange** `(clip) =\> void`

The viewer stopped on a clip. Called once it settles, not for every clip flicked past.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.