Button
A button rendered in the native navigation bar. The web side registers a title; iOS draws a UIBarButtonItem and Android a toolbar menu item; every tap is relayed back to the web side.
Outside of Hotwire Native nothing is registered and your own markup is rendered instead, so the same page still works in a regular browser.
Opening the page registers Tap me in the navigation bar; each tap is counted by the web page below it.
You copy it, you own it
There is nothing to install. The files below are the complete component — paste the web one plus whichever platforms you ship into your app, and change them however you like. They are shown straight from the hotwire-bridge-components registry, so what you see here is what the registry holds.
Web side
Save this as bridge/BridgeButton.tsx in your app:
import { useEffect, useRef, type ReactNode } from 'react'
import { useBridgeComponent } from 'inertia-hotwire-native/react'
interface BridgeButtonProps {
/** Label shown on the native navigation-bar button. */
title: string
/** Which side of the navigation bar. Defaults to the trailing edge. */
side?: 'left' | 'right'
/** Called each time the native button is tapped. */
onTap?: () => void
/** Web fallback: rendered when there is no native adapter (regular browser). */
children?: ReactNode
}
/**
* Renders a native navigation-bar button inside Hotwire Native. In a regular
* browser (no native adapter) it renders `children` as a normal web control.
*/
export function BridgeButton({ title, side = 'right', onTap, children }: BridgeButtonProps) {
const { supported, send } = useBridgeComponent('button')
// Keep the tap handler in a ref so it can change without re-registering the
// native button on every render.
const onTapRef = useRef(onTap)
onTapRef.current = onTap
useEffect(() => {
if (!supported) return
// Native replies to "connect" each time the bar button is tapped.
const id = send('connect', { title, side }, () => onTapRef.current?.())
// Drop the old callback before re-registering, so a title/side change does
// not leave a second one behind and report every tap twice.
return () => window.HotwireNative?.web?.removeCallback(id)
}, [supported, title, side, send])
if (supported) return null
return <>{children}</>
}Then use it as a component. It renders nothing when the native button is showing, and renders its children as the web fallback when it is not:
import { BridgeButton } from '@/bridge/BridgeButton'
function Article({ onSave }) {
return (
<>
<BridgeButton title="Save" onTap={onSave}>
<button type="button" onClick={onSave}>Save</button>
</BridgeButton>
{/* … */}
</>
)
}| Prop | Type | Default | Purpose |
|---|---|---|---|
title | string | — | Label on the native button |
side | 'left' | 'right' | 'right' | Which end of the navigation bar |
onTap | () => void | — | Called on every tap |
children | ReactNode | — | Web fallback, rendered only in a browser |
iOS side
Add this file to your Xcode project:
import Foundation
import HotwireNative
import UIKit
/// Native counterpart of the `button` bridge component. Draws a navigation-bar
/// button from the web side's `connect` message and relays taps back by replying
/// to that same message.
///
/// Register once with `Hotwire.registerBridgeComponents([ButtonComponent.self])`.
final class ButtonComponent: BridgeComponent {
override nonisolated class var name: String { "button" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
switch event {
case .connect:
handleConnectEvent(message: message)
}
}
// MARK: Private
private var viewController: UIViewController? {
delegate?.destination as? UIViewController
}
private func handleConnectEvent(message: Message) {
guard let data: MessageData = message.data() else { return }
let action = UIAction { [unowned self] _ in
// Reply to "connect" — the web side treats this as the tap signal.
reply(to: Event.connect.rawValue)
}
let item = UIBarButtonItem(title: data.title, primaryAction: action)
switch data.side {
case "left":
viewController?.navigationItem.leftBarButtonItem = item
default:
viewController?.navigationItem.rightBarButtonItem = item
}
}
}
// MARK: Events
private extension ButtonComponent {
enum Event: String {
case connect
}
}
// MARK: Message data
private extension ButtonComponent {
struct MessageData: Decodable {
let title: String
let side: String?
}
}Then register it at launch, in AppDelegate:
Hotwire.registerBridgeComponents([
ButtonComponent.self,
// … your other components
])Until it is registered, supported stays false on the web side and only the fallback is rendered.
The contract
Component name: button.
connect — web → native
Registers or re-registers the bar button. Sent on connect and whenever the title or side changes.
{
"title": "Save", // string, required — button label
"side": "right" // "left" | "right", optional, default "right"
}connect reply — native → web
Native replies to the same connect message every time the button is tapped. There is no separate tap event — the reply is the tap signal, and it arrives once per tap rather than once per registration.
Because a reply can arrive many times, re-registering without dropping the previous callback makes each tap fire twice. The registry component handles this; see Callback lifetime if you write your own.
Android
The Kotlin half adds an item to the destination's toolbar. Add this file to your project:
// Replace with your app's package.
package com.example.bridge
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
import dev.hotwire.navigation.destinations.HotwireDestination
import kotlinx.serialization.Serializable
// Replace with your app's R class — this is the toolbar in your destination
// layout, the same one the other bridge components drive.
import com.example.R
/**
* Native counterpart of the `button` bridge component. Adds an item to the
* destination's toolbar from the web side's `connect` message and relays taps
* back by replying to that same message.
*
* Register once with
* `Hotwire.registerBridgeComponents(BridgeComponentFactory("button", ::ButtonComponent))`.
*
* The contract's `side` is not honoured: Android toolbar menu items always sit
* at the end of the bar, so a `"left"` button still appears on the right.
*/
class ButtonComponent(
name: String,
private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {
private val buttonItemId = 41
private val fragment: Fragment
get() = delegate.destination.fragment
private val toolbar: Toolbar?
get() = fragment.view?.findViewById(R.id.toolbar)
override fun onReceive(message: Message) {
when (message.event) {
"connect" -> handleConnectEvent(message)
else -> Log.w("ButtonComponent", "Unknown event for message: $message")
}
}
private fun handleConnectEvent(message: Message) {
val data = message.data<MessageData>() ?: return
showToolbarButton(data)
}
private fun showToolbarButton(data: MessageData) {
val menu = toolbar?.menu ?: return
val order = 999 // Show as the right-most button
// Remove first, so re-connecting with a new title replaces the item
// rather than adding a second one.
menu.removeItem(buttonItemId)
menu.add(Menu.NONE, buttonItemId, order, data.title).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
// Per-item listener, so this does not clobber a toolbar-wide
// listener set by another component on the same destination.
setOnMenuItemClickListener {
performTap()
true
}
}
}
private fun performTap(): Boolean {
// Reply to "connect" — the web side treats this as the tap signal.
return replyTo("connect")
}
@Serializable
data class MessageData(
val title: String,
val side: String? = "right"
)
}Then register it at launch, in your Application:
Hotwire.registerBridgeComponents(
BridgeComponentFactory("button", ::ButtonComponent),
// … your other components
)The same page on Android. Toolbar menu items are upper-cased by the platform, so Tap me is drawn as TAP ME.
side is ignored
Android toolbar menu items always sit at the end of the bar, so a "left" button still appears on the right. Treat side as a hint that iOS honours and Android cannot.