Skip to content

Custom Tooltips

A custom tooltip is any composable you render as the tour's callout UI. Waypoint's core module doesn't ship any visual opinions, so when you use WaypointHost directly you own the tooltip entirely.

When to use a custom tooltip

Situation Recommendation
Standard onboarding with buttons, progress, title, description Use WaypointMaterial3Host from waypoint-material3. See the Material3 API.
Your app doesn't use Material3 Use WaypointHost with your own tooltipContent.
You need a custom shape, animation, or content per step Use a custom tooltip, either host-wide or per-step.
You want Material3 defaults with one or two custom steps Use WaypointMaterial3Host and override content on specific steps.

Host-level tooltip

WaypointHost requires a tooltipContent slot. It renders the tooltip of every step that has no content of its own.

WaypointHost(
    state = tourState,
    tooltipContent = { stepScope ->
        MyTooltip(stepScope)
    },
) {
    MyScreen()
}

The slot signature is @Composable (StepScope) -> Unit.

StepScope

StepScope carries everything a tooltip needs: the step's texts, where the tooltip sits, progress, and navigation.

Member Type Description
title String? Title configured on the step.
description String? Description configured on the step.
placement ResolvedPlacement? Side of the target the tooltip ended up on, null for a step without a target.
currentStepIndex Int Raw 0-based index in the full steps list, including hidden steps.
currentStepNumber Int 1-based position among currently-visible steps, for "X of Y" progress.
totalSteps Int Number of currently-visible steps (steps whose showIf passes).
isFirstStep Boolean True if the step is the first visible step.
isLastStep Boolean True if the step is the last visible step.
advancesAutomatically Boolean True when the step's advanceOn is armed for this visit, so it will move on by itself. Hide your Next button in that case, see Event-Driven Progression.
next() Advance to the next step, or complete the tour on the last one.
previous() Go back one step.
skip() Cancel the tour (the host's onTourCancel fires).

ResolvedPlacement

ResolvedPlacement is the final placement after auto-flip and space calculation. Values: Top, Bottom, Start, End. Use it to adjust styling based on which side of the target the tooltip ended up on. It is null for a step without a target, whose tooltip is centered over the host.

Complete example

A full custom tooltip with an arrow, title, description, progress dots, and Back/Next/Skip buttons:

enum class Targets { Search, Add, Profile }

@Composable
fun CustomOnboardingScreen() {
    val tourState = rememberWaypointState {
        step(Targets.Search) {
            title = "Search"
            description = "Find anything in your workspace."
        }
        step(Targets.Add) {
            title = "Create"
            description = "Add new items from anywhere."
        }
        step(Targets.Profile) {
            title = "Profile"
            description = "Manage your account and preferences."
        }
    }

    LaunchedEffect(Unit) { tourState.start() }

    WaypointHost(
        state = tourState,
        tooltipContent = { stepScope -> MyTooltip(stepScope) },
    ) {
        MyScreen(tourState)
    }
}

private val TooltipColor = Color(0xFF1B1B2F)

@Composable
fun MyTooltip(stepScope: StepScope) {
    TooltipArrowBox(arrowColor = TooltipColor) {
        Column(
            modifier = Modifier
                .widthIn(max = 320.dp)
                .clip(RoundedCornerShape(20.dp))
                .background(TooltipColor)
                .padding(20.dp),
            verticalArrangement = Arrangement.spacedBy(10.dp),
        ) {
            // Progress dots
            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                repeat(stepScope.totalSteps) { i ->
                    val isCurrent = i == stepScope.currentStepNumber - 1
                    Box(
                        modifier = Modifier
                            .size(if (isCurrent) 10.dp else 8.dp)
                            .clip(CircleShape)
                            .background(
                                if (isCurrent) Color.White else Color.White.copy(alpha = 0.3f),
                            ),
                    )
                }
            }

            stepScope.title?.let {
                Text(it, color = Color.White, style = MaterialTheme.typography.titleMedium)
            }
            stepScope.description?.let {
                Text(it, color = Color.White.copy(alpha = 0.8f))
            }

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                TextButton(onClick = { stepScope.skip() }) {
                    Text("Skip", color = Color.White.copy(alpha = 0.7f))
                }
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    if (!stepScope.isFirstStep) {
                        TextButton(onClick = { stepScope.previous() }) {
                            Text("Back", color = Color.White)
                        }
                    }
                    Button(onClick = { stepScope.next() }) {
                        Text(if (stepScope.isLastStep) "Done" else "Next")
                    }
                }
            }
        }
    }
}

Per-step overrides

A single step can provide its own tooltip composable via content { ... } in the step builder. It receives the same StepScope as the host-level slot.

rememberWaypointState {
    step(Targets.Search) {
        title = "Search"
        description = "Standard tooltip."
    }

    step(Targets.Add) {
        // Fully custom content for this step only
        content { stepScope ->
            VideoTooltip(
                videoUrl = "https://...",
                onNext = { stepScope.next() },
            )
        }
    }
}

When content is set, the host-level tooltipContent is bypassed for that step.

Positioning mechanics

Tooltips render in a Compose Popup positioned by WaypointPositionProvider. Three parameters control positioning:

Parameter On Default Description
placement step TooltipPlacement.Auto Desired side. Auto picks the side with most space.
tooltipSpacing host 12.dp Gap between target and tooltip.
screenMargin host 16.dp Minimum distance from screen edges.

TooltipPlacement values: Top, Bottom, Start, End, Auto. Even when you request a specific placement, Waypoint auto-flips to the opposite side if there isn't enough room, so StepScope.placement may differ from the placement you requested.

WaypointHost(
    state = tourState,
    tooltipSpacing = 16.dp,
    screenMargin = 24.dp,
    tooltipContent = { stepScope -> MyTooltip(stepScope) },
) { MyScreen() }

A step without a target ignores all three: its tooltip is centered over the host.

Arrows

The Material3 tooltip draws an arrow automatically. For a custom tooltip, wrap its body in TooltipArrowBox:

tooltipContent = { stepScope ->
    TooltipArrowBox(arrowColor = Color(0xFF1B1B2F)) {
        MyTooltipBody(stepScope)
    }
}
@Composable
public fun TooltipArrowBox(
    arrowColor: Color,
    modifier: Modifier = Modifier,
    arrowSize: Dp = 10.dp,
    arrowWidth: Dp = arrowSize * 2,
    content: @Composable () -> Unit,
)
Parameter Default Description
arrowColor required Arrow fill color, typically the tooltip background color.
modifier Modifier Applied to the layout holding the arrow and the content.
arrowSize 10.dp How far the arrow protrudes from the tooltip edge.
arrowWidth arrowSize * 2 Length of the arrow's base along the tooltip edge, for a flatter or sharper arrow.

The arrow sits on the edge that faces the target and keeps pointing at it when the tooltip is pushed sideways by a screen edge, in LTR and RTL layouts alike (the geometry offset is a physical distance from the tooltip's left or top edge). For a step without a target, or outside a Waypoint tooltip, TooltipArrowBox renders the bare content. It works the same way inside hint tooltips.

The tooltip's width and height are limited to the window minus the host's screenMargin on each side, so long content wraps instead of running past the margin, and a tooltip larger than the space on its side slides over the target rather than off screen.

Tip

Put the shadow, clip and background on the content inside the box, not on the box itself, otherwise the arrow gets clipped or sits inside the background.

Drawing the arrow yourself

TooltipArrowBox is built from two public pieces you can use directly: the TooltipArrow(placement, color, modifier, size, arrowWidth) composable, which draws a triangle, and LocalTooltipArrowGeometry, which tells you where it goes.

The arrow direction follows the resolved placement:

ResolvedPlacement Arrow points
Top (tooltip above target) Down
Bottom (tooltip below target) Up
Start (tooltip left of target in LTR) Right
End (tooltip right of target in LTR) Left

TooltipArrow mirrors Start/End for RTL layouts internally, so you don't have to.

LocalTooltipArrowGeometry provides a TooltipArrowGeometry(placement, arrowOffset) while tooltip content is composed for a step or hint with a target, and null otherwise. arrowOffset is the px distance of the arrow's center from the tooltip's left edge for Top/Bottom placements, and from the top edge for Start/End.

tooltipContent = { stepScope ->
    val geometry = LocalTooltipArrowGeometry.current
    Column {
        if (geometry != null && geometry.placement == ResolvedPlacement.Bottom) {
            TooltipArrow(
                placement = geometry.placement,
                color = Color(0xFF1B1B2F),
                modifier = Modifier
                    .size(width = 20.dp, height = 10.dp)
                    .offset { IntOffset((geometry.arrowOffset - 10.dp.toPx()).roundToInt(), 0) },
            )
        }
        MyTooltipBody(stepScope)
    }
}

Accessibility

The tooltip container is automatically wrapped in Modifier.semantics { liveRegion = LiveRegionMode.Polite }, so screen readers announce content when the tooltip appears or changes. You don't need to add liveRegion yourself.

Beyond that, treat the tooltip like any other surface: label icon buttons with contentDescription, ensure sufficient contrast, and keep tap targets at least 48.dp.

See also