Skip to content

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:

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
titlestring—Label on the native button
side'left' | 'right''right'Which end of the navigation bar
onTap() => void—Called on every tap
childrenReactNode—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:

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 ​

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.