Skip to content

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:

tsx
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:

jsx
import { BridgeButton } from '@/bridge/BridgeButton'

function Article({ onSave }) {
  return (
    <>
      <BridgeButton title="Save" onTap={onSave}>
        <button type="button" onClick={onSave}>Save</button>
      </BridgeButton>

      {/* … */}
    </>
  )
}
PropTypeDefaultPurpose
titlestringLabel on the native button
side'left' | 'right''right'Which end of the navigation bar
onTap() => voidCalled on every tap
childrenReactNodeWeb fallback, rendered only in a browser

iOS side

Add this file to your Xcode project:

swift
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:

swift
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.

jsonc
{
  "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:

kt
// 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:

kotlin
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.