← Back to books

Chapter 2: Compose for TV Foundations

In Chapter 1 we got a tile on screen that grows when you focus it. That was enough to prove the toolchain works. It is nowhere near enough to build an app.

This chapter builds the foundation: the component set, the type scale, the spacing system, and — most importantly — a single, enforced standard for what "focused" looks like in Hearth. By the end you'll have a design system that survives contact with a forty-dollar TV box.

We're going to be opinionated, and I'll tell you where I'm being opinionated so you can disagree on purpose rather than by accident.

2.1 Two Material 3 libraries, and the trap between them

Open your imports. Right now, in a Compose TV project, there exist two libraries with nearly identical APIs:

  • androidx.compose.material3 — the phone/tablet one you already know.
  • androidx.tv.material3 — the TV one.

They both export Surface, Text, Button, Card, Icon, MaterialTheme, ListItem, Tab, TabRow, NavigationDrawer, darkColorScheme, Typography, Shapes. Same names. Different packages. Different behaviour.

The TV versions are focus-aware. The phone versions are not — or rather, they're focus-aware in the way a phone needs, which is barely at all. If you import androidx.compose.material3.Surface and give it an onClick, it will work, it will be clickable, and it will show you nothing when it's focused. Your D-pad will move an invisible cursor around an app that gives no indication of where it is.

This is the single most common bug in new Compose TV projects, and it is nasty because it doesn't crash, doesn't warn, and looks fine in the Compose preview. It only shows up when you sit on the sofa and realise you have no idea what's selected.

The rule for Hearth, and I'd suggest for any TV project:

Never add androidx.compose.material3 to a TV module. Not as a transitive dependency you tolerate, not "just for Icon". Keep it out of the build file, and IDE autocomplete cannot betray you.

We did this already in Chapter 1 — look back at app/build.gradle.kts and note the absence. If you ever find phone Material 3 sneaking in through a library's transitive dependencies, exclude it loudly:

implementation(libs.some.library) {
    exclude(group = "androidx.compose.material3")
}

If your codebase genuinely shares composables between a phone app and a TV app, they must live in a module that depends on neither Material 3 — only on androidx.compose.foundation and androidx.compose.ui, taking colours and text styles as parameters. That's a real architecture and it works, but it's a deliberate discipline, not something you drift into.

Everything outside Material 3 — layouts, modifiers, LazyRow/LazyColumn/LazyVerticalGrid, animation, Box, Column, Row, Image — is the ordinary Compose you already know, unchanged. Only Material 3 is forked.

2.2 The component inventory

Here's what androidx.tv.material3 actually gives you, and what it doesn't. Knowing the shape of the box saves a lot of time.

Surfaces — the base of everything.

  • Surface(onClick = ...) — clickable, focusable. The workhorse.
  • Surface(selected = ..., onClick = ...) — selectable, for toggle-like things.
  • Surface(...) with no click — non-interactive container. Not focusable.

Cards — surfaces with structure.

  • Card — a bare clickable card.
  • ClassicCard — image on top, title/subtitle/description below.
  • CompactCard — text overlaid on the image with a scrim.
  • WideClassicCard — image on the left, text on the right.
  • StandardCardContainer / WideCardContainer — layouts that place a card next to external text (i.e. a poster with the title outside and below the card, which is what most streaming apps actually do).

ButtonsButton, OutlinedButton, IconButton, OutlinedIconButton, WideButton.

ListsListItem, DenseListItem.

NavigationNavigationDrawer, ModalNavigationDrawer, NavigationDrawerItem, TabRow, Tab.

TogglesCheckbox, RadioButton, Switch.

FeatureCarousel, with CarouselDefaults for the indicator row.

ThemingMaterialTheme, darkColorScheme, lightColorScheme, Typography, Shapes.

And now, what's not there, because this trips people up:

  • No Scaffold. No TopAppBar, no BottomNavigation, no FloatingActionButton, no SnackbarHost. These are phone metaphors. On TV you compose your own screen structure — usually a Box with a background layer and a content layer, or a Row with a nav drawer and a content pane. It's less than you think.
  • No TextField. There is no TV Material text field. When you need text input (Chapter 8, search), you use BasicTextField from androidx.compose.foundation and style it yourself. This is not an oversight; it's a hint that you should be avoiding text input.
  • No ImmersiveList. It existed in the alphas and was removed. The immersive browse pattern — where focusing a card changes a full-bleed background — is now something you build yourself from LazyRow + AnimatedContent. We do exactly that in Chapter 4, and honestly it's better this way; the old component was inflexible.
  • No ModalBottomSheet, no AlertDialog. For a confirmation dialog on TV you build a full-screen or centred overlay with focusable buttons. Chapter 5 covers the pattern.
  • No Slider. For the playback scrubber in Chapter 6, you build one — or use the new ProgressSlider from Media3's Compose UI module, which we'll look at.

The shape of this list tells you the philosophy: TV Material gives you focusable primitives and leaves the composition to you. That's the trade we accepted in Chapter 1 when we chose Compose over Leanback.

2.3 Focus indication: the standard

This is the most important section in the chapter.

A Surface in TV Material has four independent focus indications, and you can use any combination:

Indication What it does Works on
Scale The element grows Everywhere
Border A stroke is drawn around it Everywhere
Glow A soft coloured shadow spreads behind it API 28+ only
Colour Container/content colours change Everywhere

That glow row is not a footnote. Below API 28, tv-material silently does nothing when you specify a glow. No crash, no warning, no log line. On a Chapter 1-era operator box running Android 7, your beautiful glow-only focus indication simply does not exist, and the app is unusable.

So, Hearth's rule, which I'd defend for any TV app:

Focus must be indicated by at least two signals, at least one of which is geometric (scale or border).

Two signals, because a single signal fails for someone — colour-only fails for low-vision and colour-blind users and washes out on a bad panel; scale-only can read as a glitch on a dense grid where several tiles are partly overlapping the growth region. Geometric, because geometry survives bad panels, bad viewing angles, and old API levels.

And a second rule, which is subtler and which you will be tempted to break:

The focus indication must be identical, or near-identical, everywhere in the app.

If a card in the browse row grows 10% with an amber border, but a button on the detail page just changes colour, and a nav drawer item does something third, the user is re-learning your app on every screen. On a phone that's a papercut. On a TV, where focus is the only cursor, it's genuinely disorienting. One focus language, applied everywhere.

Let's encode it.

ui/theme/Focus.kt:

package dev.hearth.tv.ui.theme

import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceBorder
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.MaterialTheme

/**
 * Hearth's single source of truth for what "focused" looks like.
 *
 * Two signals, both geometric-safe:
 *   - scale to 108%
 *   - a 3dp border in the primary colour
 *
 * Deliberately no glow: it is silently unsupported below API 28 and we
 * refuse to ship an indication that some of our users cannot see.
 */
object HearthFocus {

    const val FOCUSED_SCALE = 1.08f
    val BORDER_WIDTH = 3.dp

    @Composable
    @ReadOnlyComposable
    fun scale(): ClickableSurfaceScale =
        ClickableSurfaceDefaults.scale(
            scale = 1.0f,
            focusedScale = FOCUSED_SCALE,
            pressedScale = 1.0f,
        )

    @Composable
    fun border(shape: Shape): ClickableSurfaceBorder =
        ClickableSurfaceDefaults.border(
            border = Border.None,
            focusedBorder = Border(
                border = BorderStroke(BORDER_WIDTH, MaterialTheme.colorScheme.primary),
                shape = shape,
            ),
            pressedBorder = Border(
                border = BorderStroke(BORDER_WIDTH, MaterialTheme.colorScheme.primary),
                shape = shape,
            ),
        )
}

Now every focusable surface in Hearth is written the same way:

Surface(
    onClick = onClick,
    shape = ClickableSurfaceDefaults.shape(shape = shape),
    colors = ClickableSurfaceDefaults.colors(/* ... */),
    scale = HearthFocus.scale(),
    border = HearthFocus.border(shape),
) { /* content */ }

One line each, impossible to get wrong, and if you decide in month six that 1.08 should be 1.06, you change it in one place.

The scale trap

A thing nobody warns you about: a scaled element overflows its layout bounds. Compose scales at draw time; the layout still thinks the tile is 220dp wide. So when a tile in a LazyRow grows to 108%, it grows over its neighbours — and, at the edge of the screen, it grows off the screen.

Two consequences:

  1. You need spacing between items that's at least as large as the growth. A 220dp tile scaled to 1.08 gains 17.6dp of width — about 9dp on each side. So Arrangement.spacedBy(24.dp) is comfortable; spacedBy(4.dp) will make focused tiles look like they're eating their neighbours.
  2. You need padding on the scroll container, or the first and last items will be clipped when focused. A LazyRow with contentPadding = PaddingValues(horizontal = 48.dp) gives the edge items room to grow into — and, conveniently, that 48dp is exactly our overscan safe area from Chapter 1. The two requirements happen to coincide, which is a small mercy.

If your focused cards look clipped at the edges of a row, this is why. Every time.

2.4 Type at three metres

The phone Material 3 type scale bottoms out around 11sp (labelSmall) and treats 14sp as body text. On a TV, 11sp is a rumour.

Here's Hearth's scale. The numbers are chosen for a 1080p canvas viewed from about 3m, and each one has a job:

ui/theme/Type.kt:

package dev.hearth.tv.ui.theme

import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Typography

val HearthTypography = Typography(
    // Hero titles on the immersive header. Big. Only used once per screen.
    displayLarge = TextStyle(
        fontSize = 57.sp, lineHeight = 64.sp, fontWeight = FontWeight.Bold,
    ),
    displayMedium = TextStyle(
        fontSize = 45.sp, lineHeight = 52.sp, fontWeight = FontWeight.Bold,
    ),

    // Screen titles, section headers.
    headlineLarge = TextStyle(
        fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.SemiBold,
    ),
    headlineMedium = TextStyle(
        fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.SemiBold,
    ),

    // Row headers ("Continue watching", "Because you watched…")
    titleLarge = TextStyle(
        fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.SemiBold,
    ),
    // Card titles.
    titleMedium = TextStyle(
        fontSize = 20.sp, lineHeight = 28.sp, fontWeight = FontWeight.Medium,
    ),

    // Body copy: synopses, descriptions. THIS IS THE SMALLEST READABLE SIZE.
    bodyLarge = TextStyle(
        fontSize = 18.sp, lineHeight = 26.sp, fontWeight = FontWeight.Normal,
    ),
    bodyMedium = TextStyle(
        fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Normal,
    ),

    // Metadata: year, rating, duration. Use sparingly; this is at the limit.
    labelLarge = TextStyle(
        fontSize = 16.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium,
    ),
)

Note what's missing: there is no bodySmall, no labelSmall. I've left them at their defaults deliberately, and the reason is a piece of practical discipline: if a designer hands you a comp with 12sp text on it, the answer is not to add a 12sp token. The answer is to push back. Anything below 16sp on a TV is, at best, decoration the user will squint at and, at worst, an accessibility failure.

If you take one number away from this section: 16sp is the floor. Not a guideline. A floor.

The line-length problem

The other typographic issue TV introduces is line length. A 1920px-wide screen with an 18sp body font, if you let text run full width, gives you lines of 150+ characters. That's roughly twice the length at which prose becomes uncomfortable to read, and at three metres — where your eye has to travel across a physically large screen to find the start of the next line — it's worse than uncomfortable, it's exhausting.

Constrain body text to a maximum width. Around 900–1000dp on a 1080p canvas, which is roughly half the screen. In practice this means a synopsis on a detail page occupies the left half of the screen and the right half is artwork, which is exactly why every streaming detail page you have ever seen looks like that. It's not a stylistic convention; it's a reading-ergonomics constraint that everyone independently discovered.

Text(
    text = synopsis,
    style = MaterialTheme.typography.bodyLarge,
    maxLines = 3,
    overflow = TextOverflow.Ellipsis,
    modifier = Modifier.widthIn(max = 900.dp),
)

Note the maxLines = 3 too. On TV, long-form text is a signal that you've misunderstood the medium. Three lines and a "More" button that opens a focused overlay is the pattern.

2.5 Spacing, and the safe area as a first-class object

Chapter 1 left 48.dp and 27.dp as magic numbers in HomeScreen. Let's fix that.

ui/theme/Spacing.kt:

package dev.hearth.tv.ui.theme

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp

/**
 * Overscan-safe area and spacing scale for a 1080p TV canvas.
 *
 * The 48/27 figures are 5% of 1920x1080 — the conventional TV safe area.
 * Content that must be seen or focused lives inside this. Background
 * imagery deliberately does not.
 */
object HearthSpacing {

    /** Horizontal overscan margin. */
    val safeH = 48.dp

    /** Vertical overscan margin. */
    val safeV = 27.dp

    /** Padding for a screen's foreground content. */
    val screen = PaddingValues(horizontal = safeH, vertical = safeV)

    /**
     * Content padding for horizontally-scrolling rows.
     *
     * Horizontal padding matches the safe area, which also gives the first
     * and last cards room to grow into when they scale on focus.
     */
    val row = PaddingValues(horizontal = safeH)

    /** Gap between cards in a row. Must exceed the focus growth (~18dp). */
    val cardGap = 24.dp

    /** Gap between rows. */
    val rowGap = 32.dp

    val xs = 4.dp
    val sm = 8.dp
    val md = 16.dp
    val lg = 24.dp
    val xl = 40.dp
}

Now HomeScreen's padding becomes Modifier.padding(HearthSpacing.screen), and the comment lives with the constant instead of in your head.

On 4K: you might expect to double these numbers. You don't. Android TV reports a 4K panel with a density such that your dp values still describe the same physical proportions of the screen; 48dp is 5% of the width on both a 1080p and a 4K panel. Your layout code doesn't change. What changes is that your images need to be higher resolution or they'll look soft — and that's a Chapter 4 problem.

2.6 Shapes and the full theme

TV Material shapes are unremarkable — the only TV-specific note is that very large corner radii read badly at distance, because the corner eats a meaningful fraction of a small card. Keep them modest.

ui/theme/Shape.kt:

package dev.hearth.tv.ui.theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Shapes

val HearthShapes = Shapes(
    extraSmall = RoundedCornerShape(4.dp),
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),   // cards
    large = RoundedCornerShape(16.dp),
    extraLarge = RoundedCornerShape(24.dp),
)

And now the real theme, replacing the sketch from Chapter 1:

ui/theme/Theme.kt:

package dev.hearth.tv.ui.theme

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme

private val HearthColors = darkColorScheme(
    // The focus colour. Everything focusable borrows this.
    primary = Color(0xFFE8B44A),
    onPrimary = Color(0xFF1A1206),

    secondary = Color(0xFF7A9CC6),
    onSecondary = Color(0xFF0A1420),

    // Near-black, not pure black: pure black on OLED makes UI edges
    // "float" and makes any dark artwork look like a hole in the screen.
    background = Color(0xFF0B0B0D),
    onBackground = Color(0xFFF2F2F5),

    surface = Color(0xFF16161A),
    onSurface = Color(0xFFF2F2F5),

    surfaceVariant = Color(0xFF24242A),
    onSurfaceVariant = Color(0xFFB8B8C0),

    error = Color(0xFFE5534B),
    onError = Color(0xFF1A0606),
)

@Composable
fun HearthTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = HearthColors,
        typography = HearthTypography,
        shapes = HearthShapes,
        content = content,
    )
}

Two colour decisions worth defending, because they're not obvious:

No light theme. Hearth is dark-only. This is the opinionated call I flagged in Chapter 1. TV viewing happens in dim rooms; a bright white UI on a 55-inch panel at night is physically unpleasant and every major streaming service has independently concluded the same. If your product genuinely needs light mode (a kids' app, a fitness app, a photo frame), build one — but do it because you have a reason, not because Material 3 has a lightColorScheme function.

Background is 0xFF0B0B0D, not 0xFF000000. On an OLED panel, pure black pixels are off. The result is that a pure-black UI background disappears into the bezel, and any dark region of a poster image becomes visually indistinguishable from "no screen there". A very slightly lifted near-black keeps the panel lit and the UI grounded. On an LCD panel it makes essentially no difference. It costs nothing and it helps on the expensive devices.

Meanwhile — remember the window background from Chapter 1's themes.xml is pure black. That's correct and different: it's there to prevent a white flash before Compose draws anything. The user sees it for a few hundred milliseconds at most.

2.7 The Hearth component: HearthCard

Let's put it together into the component we'll actually use for the rest of the book. A poster card: image, title beneath, correct focus behaviour, correct sizing.

ui/components/HearthCard.kt:

package dev.hearth.tv.ui.components

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.hearth.tv.ui.theme.HearthFocus
import dev.hearth.tv.ui.theme.HearthSpacing

/** 16:9 poster dimensions that fit ~6 across a 1080p row inside the safe area. */
private val CARD_WIDTH = 260.dp
private val CARD_HEIGHT = 146.dp

@Composable
fun HearthCard(
    title: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    image: @Composable () -> Unit = { Box(Modifier.fillMaxSize()) },
) {
    var focused by remember { mutableStateOf(false) }
    val shape = MaterialTheme.shapes.medium

    Column(modifier = modifier.width(CARD_WIDTH)) {

        Surface(
            onClick = onClick,
            modifier = Modifier
                .width(CARD_WIDTH)
                .height(CARD_HEIGHT)
                .onFocusChanged { focused = it.isFocused },
            shape = ClickableSurfaceDefaults.shape(shape = shape),
            colors = ClickableSurfaceDefaults.colors(
                containerColor = MaterialTheme.colorScheme.surfaceVariant,
                focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
            ),
            scale = HearthFocus.scale(),
            border = HearthFocus.border(shape),
        ) {
            image()
        }

        Text(
            text = title,
            style = MaterialTheme.typography.titleMedium,
            // Third signal, and the one that makes a dense grid legible:
            // the title of the focused card is bright, everything else is dim.
            color = if (focused) {
                MaterialTheme.colorScheme.onSurface
            } else {
                MaterialTheme.colorScheme.onSurfaceVariant
            },
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.padding(top = HearthSpacing.sm),
        )
    }
}

Three things to notice.

The title is outside the Surface. This is the StandardCardContainer pattern, and it's what almost every streaming app does — text below the poster, not on it. It also means the title doesn't scale with the card, which is what you want (scaled text re-rasterises and shimmers).

We hoist focus state ourselves with onFocusChanged, so the title can respond to it. This is your first taste of focus-as-application-state, and you'll do it constantly. Note we're reading isFocused, not hasFocus — we want this node, not a descendant. Chapter 3 will make you care about that distinction a great deal.

The card is 260×146. With a 24dp gap and 48dp safe margins, six of these fit across 1920px with the sixth partly visible at the edge — which is deliberate. A row whose last visible card is cut off tells the user, without any affordance, that there's more to the right. A row where everything fits exactly tells them there isn't. Use that.

2.8 A word on LazyRow and the pivot

Now that the components exist, one behaviour to understand before Chapter 4 builds the real browse screen.

When you focus your way right through a LazyRow on TV, the row does not scroll the way a phone list does. Instead of the focused item sliding to the edge and the list scrolling only when it hits the boundary, the row keeps the focused item at a roughly fixed position on screen and slides the content underneath it. This is called the pivot, and it's what makes TV rows feel right: your eye stays in one place and the catalogue moves.

You get this for free. It's the behaviour that used to live in TvLazyRow and now lives in the standard LazyRow. You don't configure it; it comes from the focus system bringing the focused item into view combined with the lazy layout's scroll behaviour.

What you do configure is contentPadding, which effectively sets where the pivot sits. Symmetric padding gives a centred pivot. Asymmetric padding — a large start, small end — pushes the pivot left, which is what most streaming apps use, because it lets you see more upcoming content than content you've already passed.

LazyRow(
    contentPadding = PaddingValues(start = 48.dp, end = 48.dp),
    horizontalArrangement = Arrangement.spacedBy(HearthSpacing.cardGap),
) { /* … */ }

Try both. Feel the difference from the sofa. It's more noticeable than it sounds.

2.9 What we built

Hearth now has a design system:

  • One Material 3 library, with the phone one locked out at the build-file level.
  • A focus standard: two signals, one geometric, no glow, identical everywhere.
  • A type scale with a 16sp floor and a max line width for body copy.
  • Spacing constants that encode the overscan safe area instead of scattering 48.dp through the codebase.
  • A dark-only colour scheme on lifted near-black.
  • HearthCard, the component the rest of the book is built from.

None of this is exotic. All of it is the difference between an app that feels like a TV app and an app that feels like a phone app someone stretched.

In Chapter 3 we stop building things that work and start fixing things that don't. Focus, properly: restoration, traps, groups, key events, and the specific bugs that will eat a week of your life if you don't see them coming.


Exercises

  1. Break the rule on purpose. Add androidx.compose.material3 to the build file, change one import in HearthCard from androidx.tv.material3.Surface to androidx.compose.material3.Surface, fix the compile errors, and run it. Observe that the card is still clickable, still navigable, and completely invisible when focused. Now remove the dependency and never speak of it again.

  2. Find your scale threshold. Build a row of eight HearthCards. Set FOCUSED_SCALE to 1.02, then 1.05, then 1.08, then 1.20. From three metres, find the smallest value where focus is unambiguous, and the largest value before it looks silly. Write both numbers down; that's your usable range.

  3. Test the 16sp floor. Render the same synopsis at 12sp, 14sp, 16sp, and 18sp. Read them from the sofa. Then get someone over 40 to do the same. (This exercise has ended more design arguments than any other.)

  4. Feel the pivot. Build a LazyRow of 30 cards with symmetric contentPadding, then with start = 200.dp, end = 48.dp. Hold right on the D-pad and watch where your eye goes.