custom-ios-selection-menu-capacitorjs.md
Custom native text-selection menus for WKWebView in Capacitor (iOS ≤15 → 26+)
How to replace the system "Copy · Look Up · Translate…" callout with your own native items — e.g. Copy · Edit or Delete · Share · Select all — inside an Ionic/Capacitor app, while leaving the default menu untouched in editable text fields.
The problem
Inside a WKWebView, the menu that appears after a text selection is not built from DOM events. It is composed natively by WKContentView (a private WebKit class), which means none of the usual web tricks reach it:
-webkit-touch-callout: none— no effect on the selection callout in iOS 16+contextmenu+preventDefault()— the menu is not event-driven- Removing/re-adding the selection via JS — the menu anchors to the selection itself
The only reliable lever is at the native layer: the methods WKContentView uses to compose the menu.
The approach: runtime swizzling
WKContentView is private, so you can't subclass it. But it does implement the menu-composition methods itself, so you can safely swap their implementations with method_setImplementation and keep a reference to the original.
Three hooks cover every iOS version:
| iOS version | Method on WKContentView |
|---|---|
| ≤ 15 | canPerformAction(_:withSender:) (UIMenuController path) |
| 16 – 25 | editMenuInteraction:menuForConfiguration:suggestedActions: |
| 26+ | buildMenuForWebViewWithBuilder: (UIMenuBuilder path) |
Tip: to find the right selector on a new iOS release, dump the class's methods with class_copyMethodList and grep for "menu". 1. The swizzles
import UIKit
import WebKit
enum SelectionCalloutSuppression {
static var suppressed = false // set by the plugin while our page is mounted
private static var installed = false
static func install() {
guard !installed,
let cls = NSClassFromString("WKContentView")
else { return }
installed = true
// iOS ≤ 15 — UIMenuController path
if let method = class_getInstanceMethod(cls, #selector(UIResponder.canPerformAction(_:withSender:))) {
let sel = #selector(UIResponder.canPerformAction(_:withSender:))
typealias IMP = @convention(c) (AnyObject, Selector, Selector, AnyObject?) -> Bool
let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
let block: @convention(block) (AnyObject, Selector, AnyObject?) -> Bool = { obj, action, sender in
if suppressed { return false } // or return a subset for custom items
return original(obj, sel, action, sender)
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
// iOS 16–25 — UIEditMenuInteraction delegate path
if #available(iOS 16.0, *) {
let menuSel = NSSelectorFromString("editMenuInteraction:menuForConfiguration:suggestedActions:")
if let method = class_getInstanceMethod(cls, menuSel) {
typealias IMP = @convention(c) (
AnyObject, Selector, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
) -> UIMenu?
let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
let block: @convention(block) (
AnyObject, UIEditMenuInteraction, UIEditMenuConfiguration, [UIMenuElement]
) -> UIMenu? = { obj, interaction, configuration, suggested in
if suppressed { return customMenu(for: obj) }
return original(obj, menuSel, interaction, configuration, suggested)
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
}
// iOS 26+ — the callout is composed via a UIMenuBuilder
let buildSel = NSSelectorFromString("buildMenuForWebViewWithBuilder:")
if let method = class_getInstanceMethod(cls, buildSel) {
typealias IMP = @convention(c) (AnyObject, Selector, AnyObject) -> Void
let original = unsafeBitCast(method_getImplementation(method), to: IMP.self)
let block: @convention(block) (AnyObject, AnyObject) -> Void = { obj, builder in
if suppressed, let menuBuilder = builder as? UIMenuBuilder {
// Skip the original entirely and insert only our items.
let inline = UIMenu(options: [.displayInline],
children: customMenu(for: obj).children)
menuBuilder.insertChild(inline, atStartOfMenu: .root)
} else {
original(obj, buildSel, builder)
}
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
}
}2. Building your custom menu
@MainActor
private static func customMenu(for obj: AnyObject) -> UIMenu {
let copy = UIAction(title: "Copy") { _ in
selectedText(from: obj) { UIPasteboard.general.string = $0 }
}
let editOrDelete = UIAction(title: "Edit or Delete") { _ in
selectedText(from: obj) { text in
// Bridge back to JS — see section 4
SelectionMenuPlugin.current?.notifyListeners(
"selectionAction",
data: ["action": "editOrDelete", "text": text ?? ""]
)
}
}
let share = UIAction(title: "Share") { _ in
selectedText(from: obj) { text in
guard let text else { return }
let vc = UIActivityViewController(activityItems: [text], applicationActivities: nil)
webView(for: obj)?.window?.rootViewController?.present(vc, animated: true)
}
}
let selectAll = UIAction(title: "Select all") { _ in
webView(for: obj)?.evaluateJavaScript(
"(() => { const el = document.querySelector('.markdown-body');"
+ "if (!el) return; const r = document.createRange();"
+ "r.selectNodeContents(el); const s = window.getSelection();"
+ "s.removeAllRanges(); s.addRange(r); })()"
)
}
return UIMenu(children: [copy, editOrDelete, share, selectAll])
}
// WKContentView → WKWebView via KVC on the private property
private static func webView(for responder: AnyObject) -> WKWebView? {
(responder as? NSObject)?.value(forKey: "webView") as? WKWebView
}
private static func selectedText(from obj: AnyObject,
completion: @escaping (String?) -> Void) {
webView(for: obj)?.evaluateJavaScript("window.getSelection().toString()") { result, _ in
completion(result as? String)
}
}3. A Capacitor plugin to toggle it
The suppressed flag scopes the custom menu to a single page — here, any non-editable content screen. Editable inputs everywhere else keep the traditional menu.
import Capacitor
@objc(SelectionMenuPlugin)
public class SelectionMenuPlugin: CAPPlugin, CAPBridgedPlugin {
static weak var current: SelectionMenuPlugin?
public let identifier = "SelectionMenuPlugin"
public let jsName = "SelectionMenu"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise),
]
public override func load() {
SelectionMenuPlugin.current = self
SelectionCalloutSuppression.install()
}
@objc func enable(_ call: CAPPluginCall) {
SelectionCalloutSuppression.suppressed = true
call.resolve()
}
@objc func disable(_ call: CAPPluginCall) {
SelectionCalloutSuppression.suppressed = false
call.resolve()
}
}
// Register it without touching the .pbxproj: subclass the bridge VC
class MainViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
bridge?.registerPluginInstance(SelectionMenuPlugin())
}
}Then use MainViewController() as the rootViewController in your SceneDelegate / AppDelegate.
4. The JS side
import { Capacitor, registerPlugin } from '@capacitor/core';
const SelectionMenu = registerPlugin('SelectionMenu');
// On mount of the page that needs the custom menu:
await SelectionMenu.enable();
SelectionMenu.addListener('selectionAction', ({ action, text }) => {
if (action === 'editOrDelete') {
// e.g. route to your composer with `text` seeded
}
});
// On unmount:
await SelectionMenu.disable();Caveats
- Private API surface:
WKContentViewandbuildMenuForWebViewWithBuilder:are private WebKit internals. It works on iOS 26/27 today, but validate on each new major release and consider the App Store review implications of touching private classes. - Probe before you swizzle: when a new iOS version breaks the hook, dump selectors again — the composition path moved twice already (16 → 26).
- Gate by flag, not by content sniffing: probing "is this selection editable?" via
canPerformAction cut:/paste:is clipboard-dependent and unreliable; scoping with an explicit enable/disable flag per page is deterministic. UIMenuBuilderonly acceptsUIMenuchildren/siblings — wrap individualUIActions inUIMenu(options: [.displayInline], children:)to render them as flat items.
*Tested on iOS 26.5 and 27.0 simulators (Ionic Vue 3 + Capacitor 8).*