Button
A button rendered in the native navigation bar. The web side registers a title; iOS draws a UIBarButtonItem; 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. Both files below are the complete component — paste them 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 |
The file is TypeScript. Vite compiles .tsx with no configuration change, even in a project that is otherwise plain .jsx — types are stripped by esbuild. Without typescript and a tsconfig.json they are not checked, only removed.
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 component overrides name as override nonisolated class var name. The base declaration is nonisolated, so an app built with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor — the Xcode 26 default for new projects — rejects a plain override class var name as an actor-isolation mismatch.
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
Not covered here yet. The registry ships a ButtonComponent.kt, but it has not been verified against a pinned Android SDK version — treat it as unversioned until it has.