Portal
Children rendered elsewhere in the view, over everything or into a host.
Portal renders its children elsewhere in the plugin view while they stay
in their component's React tree: their context, state and effects are kept.
It is a Soundor extension, not a React Native component, built on the
renderer's own portals: no ReactDOM, and no other window.
A tooltip is the trigger's box in surface coordinates and a portal:
import { Portal, Text, View } from '@soundor/react';
import { useRef, useState } from 'react';
import type { LayoutRect, UiNode } from 'soundor:ui';
// A tooltip: the trigger's box in surface coordinates, and a portal that
// draws above everything else, out of any clipping.
function Hint({ label }: { label: string }) {
const ref = useRef<UiNode>(null);
const [box, setBox] = useState<LayoutRect | null>(null);
return (
<View
ref={ref}
onPointerEnter={() => setBox(ref.current!.getBoundingClientRect())}
onPointerLeave={() => setBox(null)}
style={{
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#3a7bfd',
}}
>
<Text style={{ color: 'white' }}>?</Text>
{box && (
<Portal>
<View
style={{
position: 'absolute',
left: box.x,
top: box.y + box.height + 6,
padding: 8,
borderRadius: 6,
backgroundColor: '#e6e7ea',
}}
>
<Text style={{ color: '#17181c' }}>{label}</Text>
</View>
</Portal>
)}
</View>
);
}
export default function Tooltip() {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<Text style={{ color: '#e6e7ea', fontSize: 16 }}>Drive</Text>
<Hint label="Saturation before the filter" />
</View>
);
}tooltip.tsxSourceOver everything
<Portal> (also host={null}) renders into the view's overlay layer:
- above all of the content, whatever its
zIndex, and out of anyoverflowclipping or scrolling; - in surface coordinates: place it with an event's
pageX/pageYor a node'sgetBoundingClientRect()(coordinates); - filling the view, letting the pointer through where it draws nothing;
- above the portals opened before it, so one opened from inside another (a dropdown in a dialog) is on top of it.
There is one overlay layer, not one per kind of overlay, and it is not
exported: <Portal> is the way in. A context menu is onContextMenu, the
pointer's position and a portal:
import { Portal, Pressable, Text, View } from '@soundor/react';
import { useState } from 'react';
const ACTIONS = ['Copy', 'Paste', 'Reset to default'];
// Right-click (or Ctrl-click) the track: onContextMenu gives the pointer's
// position in the view, and a portal draws the menu there, above everything.
export default function ContextMenu() {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [last, setLast] = useState('nothing yet');
return (
<View style={{ alignItems: 'center', gap: 10 }}>
<View
onContextMenu={(event) => {
event.preventDefault();
setMenu({ x: event.pageX, y: event.pageY });
}}
onPointerDown={(event) => {
if (event.button === 0) setMenu(null);
}}
style={{
width: 260,
padding: 16,
borderRadius: 8,
backgroundColor: '#24262c',
}}
>
<Text style={{ color: '#e6e7ea' }}>Track 1 · right-click me</Text>
</View>
<Text style={{ color: '#8b8f98' }}>Last action: {last}</Text>
{menu && (
<Portal>
<View
style={{
position: 'absolute',
left: menu.x,
top: menu.y,
padding: 4,
borderRadius: 8,
backgroundColor: '#e6e7ea',
}}
>
{ACTIONS.map((action) => (
<Pressable
key={action}
accessibilityRole="menuitem"
onPress={() => {
setLast(action);
setMenu(null);
}}
style={({ hovered }) => ({
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 4,
backgroundColor: hovered ? '#c9d8ff' : 'transparent',
})}
>
<Text style={{ color: '#17181c' }}>{action}</Text>
</Pressable>
))}
</View>
</Portal>
)}
</View>
);
}context-menu.tsxSourceInto a host
<Portal host={host}> renders into the <Portal.Host host={host}>, as its
children: clipped, stacked and scrolled wherever that host is. Hosts are
opaque handles from createPortalHost().
const panelHost = createPortalHost();
<View style={{ overflow: 'hidden' }}>
<Portal.Host
host={panelHost}
style={{ position: 'absolute', inset: 0, pointerEvents: 'box-none' }}
/>
</View>
<Portal host={panelHost}>…</Portal>Content waits, rendering nothing, until its host mounts, goes when the host unmounts, and comes back with it. Showing one host in two places at once is an error.
Events and focus
Pointer and key events bubble through the nodes as they are shown: from a
portal's content up to its host (or the overlay layer), not through the
component that declared it. Focus follows React: content a FocusScope
renders through a portal belongs to that scope.
Props
Prop
Type
Portal.Host:
Prop
Type