Sheet
Bottom/top/left/right sheet built on @rn-primitives/dialog and styled from RNR's dialog. Mounts its OWN named PortalHost so nested overlays layer above it instead of behind. Backs drawer, command, sidebar, panel, open-in-chat and model-selector.
- v0.2.1
- ui
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/sheet.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/sheet.jsonDependencies
@rn-primitives/dialog@rn-primitives/portalclass-variance-authorityreact-native-reanimatedreact-native-screens
https://reactnativereusables.com/r/nativewind/text.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://reactnativereusables.com/r/uniwind/icon.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { cn } from '@/registry/nativewind/lib/utils';
import * as DialogPrimitive from '@rn-primitives/dialog';
import { PortalHost } from '@rn-primitives/portal';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, View, type GestureResponderEvent } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
ReduceMotion,
SlideInDown,
SlideInLeft,
SlideInRight,
SlideInUp,
SlideOutDown,
SlideOutLeft,
SlideOutRight,
SlideOutUp,
} from 'react-native-reanimated';
import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens';
/**
* Sheet — the most load-bearing primitive in this registry.
*
* RNR ships no sheet, drawer, resizable or sidebar. This one backs all of them plus
* command, panel, open-in-chat and model-selector, because on a phone a dropdown, a
* command palette and a side panel all collapse to the same surface.
*
* Built on @rn-primitives/dialog, styled from RNR's own dialog so the two are visually one
* family: same overlay (bg-black/50), same FullWindowOverlay guard on iOS, same
* ReduceMotion handling.
*
* THE CRITICAL DETAIL — SheetContent mounts its OWN NAMED PortalHost. An RNR Select or
* DropdownMenu opened from inside a sheet portals to the ROOT host and renders BEHIND the
* sheet. There is no error; it just appears to do nothing, and it reproduces only on
* device. Pass `portalHost={useSheetPortalHost()}` to any overlay you nest in here.
*/
const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment;
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
type Side = 'bottom' | 'top' | 'left' | 'right';
const SheetPortalHostContext = React.createContext<string | undefined>(undefined);
/** Name of the sheet's own portal host. Pass to nested RNR overlays. */
function useSheetPortalHost() {
return React.useContext(SheetPortalHostContext);
}
const sheetVariants = cva('absolute z-50 gap-4 border-border bg-background p-6 shadow-sm shadow-black/5', {
variants: {
side: {
bottom: 'bottom-0 left-0 right-0 rounded-t-xl border-t',
top: 'left-0 right-0 top-0 rounded-b-xl border-b',
left: 'bottom-0 left-0 top-0 w-3/4 max-w-sm rounded-r-xl border-r',
right: 'bottom-0 right-0 top-0 w-3/4 max-w-sm rounded-l-xl border-l',
},
},
defaultVariants: { side: 'bottom' },
});
const ENTER = { bottom: SlideInDown, top: SlideInUp, left: SlideInLeft, right: SlideInRight };
const EXIT = { bottom: SlideOutDown, top: SlideOutUp, left: SlideOutLeft, right: SlideOutRight };
function SheetOverlay({
className,
onPress,
...props
}: Omit<React.ComponentProps<typeof DialogPrimitive.Overlay>, 'asChild'>) {
const { onOpenChange } = DialogPrimitive.useRootContext();
function onOverlayPress(event: GestureResponderEvent) {
onPress?.(event);
if (event.target === event.currentTarget && !event.isDefaultPrevented()) onOpenChange(false);
}
return (
<DialogPrimitive.Overlay
className={cn('absolute bottom-0 left-0 right-0 top-0 z-50 bg-black/50', className)}
onPress={onOverlayPress}
{...props}
/>
);
}
type SheetContentProps = Omit<React.ComponentProps<typeof DialogPrimitive.Content>, 'asChild'> &
VariantProps<typeof sheetVariants> & {
/** Unique host name. Defaults to a generated one; set it if you nest sheets. */
portalHostName?: string;
};
function SheetContent({ className, side = 'bottom', portalHostName, children, ...props }: SheetContentProps) {
const generated = React.useId();
const hostName = portalHostName ?? `sheet-${generated}`;
const s = (side ?? 'bottom') as Side;
return (
<DialogPrimitive.Portal>
<FullWindowOverlay>
<SheetOverlay />
<Animated.View
entering={ENTER[s].duration(200).reduceMotion(ReduceMotion.System)}
exiting={EXIT[s].duration(150).reduceMotion(ReduceMotion.System)}
className={cn(sheetVariants({ side: s }), className)}
>
<DialogPrimitive.Content {...props}>
<SheetPortalHostContext.Provider value={hostName}>
{s === 'bottom' ? (
// Grab handle — the affordance that says this is draggable/dismissable.
<View className="mx-auto h-1 w-10 rounded-full bg-muted-foreground/40" />
) : null}
{children}
{/* Nested overlays portal HERE, not to the root, or they render behind. */}
<PortalHost name={hostName} />
</SheetPortalHostContext.Provider>
</DialogPrimitive.Content>
</Animated.View>
</FullWindowOverlay>
</DialogPrimitive.Portal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<typeof View>) {
return <View className={cn('gap-1.5', className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<typeof View>) {
return <View className={cn('flex-row justify-end gap-2 pt-2', className)} {...props} />;
}
const SheetTitle = DialogPrimitive.Title;
const SheetDescription = DialogPrimitive.Description;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetTitle,
SheetTrigger,
sheetVariants,
useSheetPortalHost,
};
export const FadeInOut = { FadeIn, FadeOut };
Theming
Themed via border-border, bg-background, bg-muted-foreground.
Usage
() => (
<Sheet>
<SheetTrigger>
<Text className="text-sm text-primary">Open the install sheet</Text>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>
<Text className="text-sm font-medium text-foreground">Install conversation.json</Text>
</SheetTitle>
<SheetDescription>
<Text className="text-sm text-muted-foreground">
Runs the real RNR CLI against the v0.2.0 tag.
</Text>
</SheetDescription>
</SheetHeader>
<SheetClose>
<Text className="text-sm text-primary">Close</Text>
</SheetClose>
</SheetContent>
</Sheet>
)