A lightweight Android TV focus effect library for View and Jetpack Compose. Add delayed focus borders, moving highlight trails, glow, and scale animations to TV cards with a drop-in View or pure Compose wrapper.
The reusable code lives in focus-trail and focus-trail-compose. The app module is only a TV-style sample page.
- Plain Android View drawing.
- Overlay helper for existing Views that cannot be wrapped.
- Optional pure Compose Canvas implementation.
- No AppCompat or Material dependency.
- Works with XML or programmatic View layouts.
focus-trail: reusable Android Library module.focus-trail-compose: pure Compose Canvas implementation for Compose callers.app: sample app that shows View and Compose demos.
- Android Gradle Plugin:
9.2.1 - Gradle:
9.5.1 - Kotlin / Compose compiler plugin:
2.4.0 - Jetpack Compose BOM:
2026.06.01 - Activity Compose:
1.13.0 - Java toolchain for local builds: JDK
21 compileSdk:37targetSdk:37
Use JitPack after pushing a GitHub release tag:
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation "com.github.holtchas.focus-trail:focus-trail:0.2.0"
// Optional Compose implementation.
implementation "com.github.holtchas.focus-trail:focus-trail-compose:0.2.0"
}If the library is later released to Maven Central:
dependencies {
implementation "dev.holtchas:focus-trail:0.2.0"
implementation "dev.holtchas:focus-trail-compose:0.2.0"
}For local development, publish the libraries to Maven Local:
./gradlew :focus-trail:publishToMavenLocal :focus-trail-compose:publishToMavenLocalThen consume the View library:
repositories {
mavenLocal()
}
dependencies {
implementation "dev.holtchas:focus-trail:0.2.0"
}Or consume the Compose implementation:
repositories {
mavenLocal()
}
dependencies {
implementation "dev.holtchas:focus-trail-compose:0.2.0"
}Inside this repo, the sample app uses project dependencies:
dependencies {
implementation project(":focus-trail")
implementation project(":focus-trail-compose")
}If you control the card layout, wrap your content with FocusTrailLayout.
This is the simplest drop-in View entry point and uses the same delayed
focus-trail timing as the sample app:
<dev.holtchas.focustrail.FocusTrailLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="240dp"
android:layout_height="160dp"
android:focusable="true"
android:padding="16dp"
app:focusTrailStartDelay="1000"
app:focusTrailCornerRadius="20dp"
app:focusTrailPadding="12dp"
app:focusTrailBorderWidth="2.8dp"
app:focusTrailStaticBorderWidth="2.8dp"
app:focusTrailGlowWidth="12dp"
app:focusTrailStaticColor="#7EFFFFFF"
app:focusTrailColor="#FFFFFFFF"
app:focusTrailBaseAlpha="126"
app:focusTrailGlowAlpha="50"
app:focusTrailHighlightAlpha="255"
app:focusTrailLengthRatio="0.16"
app:focusTrailMinLength="42dp"
app:focusTrailDrawOppositeTrail="true"
app:focusTrailScale="1.08">
<!-- Your poster/card content. -->
</dev.holtchas.focustrail.FocusTrailLayout>If the page already has production Views and you do not want to wrap them, bind the effect directly:
FocusTrailHelper.bindTrailScaleFocus(
focusedView -> updatePreview(focusedView),
FocusTrailHelper.DEFAULT_FOCUSED_SCALE,
posterCard,
secondPosterCard,
thirdPosterCard);Use FocusTrailLayout when you control the layout:
<dev.holtchas.focustrail.FocusTrailLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="240dp"
android:layout_height="160dp"
android:focusable="true"
android:padding="16dp"
app:focusTrailShape="roundRect"
app:focusTrailDuration="10000"
app:focusTrailStartDelay="1000"
app:focusTrailCornerRadius="20dp"
app:focusTrailPadding="12dp"
app:focusTrailBorderWidth="2.8dp"
app:focusTrailStaticBorderWidth="2.8dp"
app:focusTrailGlowWidth="12dp"
app:focusTrailStaticColor="#7EFFFFFF"
app:focusTrailColor="#FFFFFFFF"
app:focusTrailBaseAlpha="126"
app:focusTrailGlowAlpha="50"
app:focusTrailHighlightAlpha="255"
app:focusTrailLengthRatio="0.16"
app:focusTrailMinLength="42dp"
app:focusTrailDrawInnerTrail="true"
app:focusTrailDrawOppositeTrail="true"
app:focusTrailScale="1.08">
<!-- Your ImageView/TextView/card content here. -->
</dev.holtchas.focustrail.FocusTrailLayout>For a circle:
app:focusTrailShape="circle"
app:focusTrailDuration="6500"
app:focusTrailLengthRatio="0.28"Programmatic configuration is also supported:
FocusTrailLayout focus = new FocusTrailLayout(context);
focus.setShape(FocusTrailLayout.SHAPE_ROUND_RECT);
focus.setDurationMs(10000L);
focus.setStartDelayMs(1000L);
focus.setCornerRadius(dp(20));
focus.setTrailPadding(dp(12));
focus.setBorderWidth(dp(2.8f));
focus.setStaticBorderWidth(dp(2.8f));
focus.setGlowWidth(dp(12));
focus.setBaseAlpha(126);
focus.setGlowAlpha(50);
focus.setHighlightAlpha(255);
focus.setTrailLengthRatio(0.16f);
focus.setMinTrailLength(dp(42));
focus.setDrawOppositeTrail(true);
focus.setFocusScale(1.08f);Use FocusTrailHelper when a production page already has its own layout. This
path gives existing cards the same scale animation and overlay trail without
changing their parent layout:
FocusTrailHelper.bindTrailScaleFocus(
view -> lastFocusedId = view.getId(),
FocusTrailHelper.DEFAULT_FOCUSED_SCALE,
cardView);For banner or pill-style controls, use the bundled TV presets:
bannerView.setOnFocusChangeListener((view, hasFocus) ->
FocusTrailHelper.applyBannerTrailFocus(view, hasFocus));
statusView.setOnFocusChangeListener((view, hasFocus) ->
FocusTrailHelper.applyStatusPillTrail(
view,
hasFocus,
FocusTrailHelper.WIDE_BUTTON_FOCUSED_SCALE));You can still tune the overlay trail parameters directly. These values are in dp so they stay close to visual design specs:
FocusTrailEffect.Config config = FocusTrailHelper
.newTrailConfigBuilder(FocusTrailHelper.DEFAULT_FOCUSED_SCALE)
.trailPaddingDp(12f)
.borderWidthDp(2.8f)
.glowWidthDp(12f)
.baseAlpha(126)
.glowAlpha(50)
.highlightAlpha(255)
.highlightLengthFraction(0.16f)
.minHighlightLengthDp(42f)
.headRadiusDp(4.8f)
.build();
cardView.setOnFocusChangeListener((view, hasFocus) ->
FocusTrailHelper.applyTrailScale(
view,
hasFocus,
FocusTrailHelper.DEFAULT_FOCUSED_SCALE,
config));For matching rounded content and focus radius, use the provided base widgets:
<dev.holtchas.focustrail.FocusTrailRoundedImageView
android:id="@+id/poster"
android:layout_width="180dp"
android:layout_height="120dp"
android:scaleType="centerCrop"
android:focusable="true"
app:focusTrailClipRadius="20dp" />Wrap any composable content with FocusTrailBox. For most apps, create a
reusable style once and pass it to every card:
val focusStyle = FocusTrailDefaults.tvCardStyle(
shape = FocusTrailShape.ROUND_RECT,
durationMs = 10_000L,
startDelayMs = 1_000L,
cornerRadius = 20.dp,
borderWidth = 2.8.dp,
trailPadding = 12.dp,
glowWidth = 12.dp,
baseAlpha = 126,
glowAlpha = 50,
highlightAlpha = 255,
trailLengthRatio = 0.16f,
minTrailLength = 42.dp,
drawOppositeTrail = true,
focusScale = 1.08f
)
FocusTrailBox(
modifier = Modifier.size(width = 240.dp, height = 160.dp),
active = isFocused,
style = focusStyle
) {
// Your Compose card content here.
}If you need lower-level control, FocusTrailBox still exposes the raw px-based
parameters directly.
focusTrailShape:roundRectorcircle.focusTrailDuration: one full loop duration in milliseconds.focusTrailStartDelay: delay before the base border begins fading in.focusTrailCornerRadius: rounded-rectangle corner radius.focusTrailPadding: space reserved around content for border and glow.focusTrailBorderWidth: moving inner highlight width.focusTrailStaticBorderWidth: base border width.focusTrailGlowWidth: outer glow stroke width.focusTrailStaticColor: base border color. In overlay mode, alpha is controlled byfocusTrailBaseAlpha.focusTrailColor: moving highlight and glow color.focusTrailBaseAlpha: base border opacity, 0-255.focusTrailGlowAlpha: outer glow opacity, 0-255.focusTrailHighlightAlpha: inner moving highlight opacity, 0-255.focusTrailLengthRatio: highlight length as a ratio of the full path.focusTrailMinLength: minimum moving highlight length.focusTrailDrawInnerTrail: whether to draw the inner border/highlight.focusTrailDrawOppositeTrail: whether to draw a second highlight opposite the first one.focusTrailScale: scale applied while focused.focusTrailUseOverlayEffect: keeptruefor the overlay engine; setfalseonly for the legacy self-drawing container.focusTrailAutoRaiseZ: whether the focused view is raised above siblings while focused.focusTrailDisableAncestorClipping: whether ancestorViewGroupclipping is disabled so glow can draw outside the card bounds.focusTrailAutoStart: legacy self-drawing mode only.focusTrailSegmentCount: legacy self-drawing mode and Compose only.focusTrailClipRadius: rounded clipping radius forFocusTrailRoundedImageViewandFocusTrailRoundedFrameLayout.
The Compose API mirrors the same core options through FocusTrailStyle,
FocusTrailDefaults.tvCardStyle(...), and raw FocusTrailBox(...) parameters,
plus active for caller-owned focus state.
./gradlew :focus-trail:assembleRelease :focus-trail-compose:assembleRelease :app:assembleDebug./gradlew :focus-trail:publishToMavenLocal :focus-trail-compose:publishToMavenLocaladb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n dev.holtchas.focuslab/com.elep.focuslab.FocusCompareActivityThe sample page borrows the Android TV immersive-list structure: preview background, metadata panel, and a horizontal focusable content row. Demo artwork is AI-generated fictional content art with no real film, game, logo, or brand reference.
The sample app has a small Native View / Compose switch so both implementations show the same focus-trail effect without extra visual modes.
MIT
