@peersahab/side-island
v0.2.2
Published
A Skia-powered side island overlay for React Native with built-in virtualized content.
Maintainers
Readme
@peersahab/side-island
A Skia-powered side island overlay for React Native, with an internal virtualized list (FlatList) and an optional Provider + hooks control layer. Perfect for quick access menus, color pickers, and other side-mounted UI components.
Install
npm i @peersahab/side-islandPeer dependencies (required)
This library expects these to be installed in your app:
npm i @shopify/react-native-skia react-native-reanimatedAppearance
The SideIsland component can be positioned on either side of the screen and displays a smooth, wave-shaped overlay with a virtualized list of items.
Right Position

Left Position

Quick Start
Note: The island opens and closes programmatically only. There is no visible handle or user interaction to toggle it. Control it via the
expandedprop,onToggleExpandedcallback, or theuseSideIslandhook.
Basic Usage
import React, { useState } from "react";
import { View, Button } from "react-native";
import { SideIsland } from "@peersahab/side-island";
export function Example() {
const [expanded, setExpanded] = useState(false);
const items = Array.from({ length: 40 }).map((_, i) => ({ id: String(i) }));
return (
<View style={{ flex: 1 }}>
<Button title="Toggle Island" onPress={() => setExpanded(!expanded)} />
<SideIsland
expanded={expanded}
onToggleExpanded={setExpanded}
items={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: "#00000022" }} />
)}
/>
</View>
);
}With Haptics
import React from "react";
import * as Haptics from "expo-haptics";
import { SideIsland } from "@peersahab/side-island";
export function Example() {
return (
<SideIsland
items={items}
renderItem={({ item }) => <YourItem item={item} />}
haptics={{
onOpen: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
onClose: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
onFocusChange: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Rigid),
}}
/>
);
}With Backdrop and Focused Item Detail
import React from "react";
import { BlurView } from "expo-blur";
import { useWindowDimensions } from "react-native";
import { SideIsland } from "@peersahab/side-island";
export function Example() {
const { width, height } = useWindowDimensions();
const [expanded, setExpanded] = useState(false);
return (
<SideIsland
expanded={expanded}
onToggleExpanded={setExpanded}
items={people}
renderItem={({ item }) => <AvatarItem person={item} />}
backdropComponent={
<BlurView intensity={20} style={{ width, height }} tint="dark" />
}
renderFocusedItemDetail={({ item }) => (
<View>
<Text>{item.name}</Text>
<Text>{item.email}</Text>
</View>
)}
/>
);
}Provider + Hook Pattern
Setup Provider
Wrap your app once with the provider:
import React from "react";
import { SideIslandProvider } from "@peersahab/side-island";
export function AppRoot() {
return (
<SideIslandProvider
defaultExpanded={false}
config={{
position: "right",
width: 40,
height: 300,
}}
>
{/* your navigation tree */}
</SideIslandProvider>
);
}Control from Anywhere
Use the hook to control the island from any component:
import React from "react";
import { Button } from "react-native";
import { useSideIsland } from "@peersahab/side-island";
export function ToggleIslandButton() {
const island = useSideIsland();
return (
<Button
title={island.expanded ? "Close" : "Open"}
onPress={island.toggle}
/>
);
}Render Island (uses Provider state)
When using the provider, the island automatically uses the provider's state:
import React from "react";
import { SideIsland } from "@peersahab/side-island";
export function IslandOverlay() {
return (
<SideIsland
items={items}
renderItem={({ item }) => <YourItem item={item} />}
// No need to pass expanded/onToggleExpanded - uses provider state
/>
);
}Drag and Drop
The library supports drag-and-drop functionality, allowing users to drag items from the island and drop them into custom drop zones anywhere in your app.
Setup
First, wrap your app with SideIslandDndProvider:
import React from "react";
import { SideIslandDndProvider } from "@peersahab/side-island";
export function AppRoot() {
return (
<SideIslandDndProvider>
{/* your app */}
</SideIslandDndProvider>
);
}Enable Drag on Island
Enable drag-and-drop on your island:
import React from "react";
import { SideIsland } from "@peersahab/side-island";
export function MyIsland() {
return (
<SideIsland
items={items}
renderItem={({ item }) => <YourItem item={item} />}
enableDragAndDrop={true}
islandId="my-island"
onDragStart={({ item, index, islandId }) => {
console.log("Drag started:", item);
}}
onDragEnd={({ item, index, islandId, dropResult }) => {
if (dropResult) {
console.log("Dropped in:", dropResult.dropZoneId);
} else {
console.log("Drag cancelled");
}
}}
/>
);
}Create Drop Zones
Use DroppableContainer to create drop zones anywhere in your app:
import React, { useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { DroppableContainer } from "@peersahab/side-island";
export function MyScreen() {
const [droppedItems, setDroppedItems] = useState([]);
return (
<View style={styles.container}>
<DroppableContainer
dropZoneId="my-drop-zone"
onDrop={({ dropZoneId, payload }) => {
console.log("Item dropped:", payload.item);
setDroppedItems([...droppedItems, payload.item]);
}}
style={styles.dropZone}
>
{({ isOver, isDragging }) => (
<View
style={[
styles.dropZoneContent,
isOver && styles.dropZoneActive,
]}
>
<Text>
{isOver ? "Drop here!" : "Drop zone"}
</Text>
</View>
)}
</DroppableContainer>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
dropZone: {
flex: 1,
borderWidth: 2,
borderColor: "#ccc",
borderRadius: 8,
padding: 20,
},
dropZoneContent: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
dropZoneActive: {
backgroundColor: "#e3f2fd",
borderColor: "#2196f3",
},
});Custom Drag Payload
Customize the data sent when dragging:
<SideIsland
enableDragAndDrop={true}
getDragPayload={({ item, index }) => ({
customData: item.someProperty,
timestamp: Date.now(),
})}
renderDragPreview={({ item, index }) => (
<View style={{ opacity: 0.8 }}>
<YourCustomPreview item={item} />
</View>
)}
// ... other props
/>API Reference
SideIsland<ItemT>
The main component that renders the side island overlay.
Required Props
| Prop | Type | Description |
|------|------|-------------|
| items | readonly ItemT[] | Array of items to display in the island |
| renderItem | (info: { item: ItemT; index: number }) => React.ReactElement \| null | Function to render each item |
Optional Props
List Configuration
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| keyExtractor | (item: ItemT, index: number) => string | (_, index) => String(index) | Function to extract unique keys for items |
| listProps | Omit<FlatListProps<ItemT>, "data" \| "renderItem" \| "keyExtractor"> | - | Additional props to pass to the internal FlatList |
| renderItemWrapper | (info: { index: number; scrollY: SharedValue<number>; itemHeight: SharedValue<number>; viewportHeight: number; separatorHeight: number; children: React.ReactNode }) => React.ReactElement | - | Optional wrapper for each item to implement custom scroll-based animations (defaults to built-in scaling) |
| onFocusedItemChange | (info: { item: ItemT; index: number } \| null) => void | - | Called when the focused item changes (item closest to center) |
Position & Layout
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| position | "left" \| "right" | "right" | Which side of the screen the island is pinned to |
| width | number | 40 | Width of the island in pixels |
| height | number | 250 | Height of the island in pixels |
| topOffset | number | 0 | Vertical offset from center in pixels (positive = down, negative = up) |
| style | ViewStyle | - | Additional styles for the island container |
Wave Shape (Advanced)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| waveAmplitude | number | 18 | Amplitude of the wave bulge (controls how much it extends) |
| waveY1 | number | 0.1 | Top wave peak position (0-1, relative to height) |
| waveY2 | number | 0.9 | Bottom wave peak position (0-1, relative to height) |
Appearance
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| backgroundColor | string | "#000000" | Background color of the island |
State Management
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| expanded | boolean | - | Controlled expanded state (if provided, component is controlled) |
| onToggleExpanded | (next: boolean) => void | - | Callback when expanded state should change (required if expanded is provided) |
| defaultExpanded | boolean | false | Initial expanded state (only used if uncontrolled) |
Interaction
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| haptics | SideIslandHaptics | - | Haptics adapter for feedback (see Haptics section below) |
Advanced Features
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| backdropComponent | React.ReactElement | - | Component to render as backdrop (fades in when expanded) |
| renderFocusedItemDetail | (info: { item: ItemT; index: number; expanded: boolean; setExpanded: (next: boolean) => void }) => React.ReactElement \| null | - | Component to render details of the focused item (displayed opposite the island) |
| focusedItemDetailGap | number | 16 | Horizontal gap between focused item detail and island |
Drag and Drop
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| enableDragAndDrop | boolean | false | Enable drag-and-drop functionality for island items |
| islandId | string | "default" | Unique identifier for this island (used in drag callbacks) |
| getDragPayload | (info: { item: ItemT; index: number }) => unknown | (info) => ({ item, index }) | Custom function to extract drag payload from an item |
| renderDragPreview | (info: { item: ItemT; index: number }) => React.ReactElement \| null | - | Custom render function for the drag preview that follows the finger |
| onDragStart | (info: { item: ItemT; index: number; islandId: string }) => void | - | Called when a drag operation starts |
| onDragEnd | (info: { item: ItemT; index: number; islandId: string; dropResult: null \| { dropZoneId: string } }) => void | - | Called when a drag operation ends. dropResult is null if item was not dropped in a valid drop zone |
SideIslandDndProvider
Provider component for managing drag-and-drop state globally. Must wrap your app when using drag-and-drop.
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| children | React.ReactNode | - | Child components |
| renderDragPreview | (payload: DragPayload) => React.ReactElement \| null | - | Optional global drag preview renderer (overrides per-island previews) |
DroppableContainer
Component that creates a drop zone where island items can be dropped.
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| dropZoneId | string | - | Unique identifier for this drop zone (required) |
| onDrop | (info: { dropZoneId: string; payload: DragPayload }) => void | - | Called when an item is dropped in this zone (required) |
| accepts | (payload: DragPayload) => boolean | - | Optional function to filter which items can be dropped (returns true to accept) |
| onDragEnter | (payload: DragPayload) => void | - | Called when a dragged item enters this drop zone |
| onDragLeave | (payload: DragPayload) => void | - | Called when a dragged item leaves this drop zone |
| children | React.ReactNode \| ((state: { isOver: boolean; isDragging: boolean }) => React.ReactNode) | - | Content to render. Can be a function that receives drag state |
| style | ViewStyle | - | Additional styles for the container |
useSideIslandDnd()
Hook to access the drag-and-drop context. Returns null if provider is not present.
Returns
{
isDraggingSV: SharedValue<boolean>;
dragXSV: SharedValue<number>;
dragYSV: SharedValue<number>;
activeDropZoneIdSV: SharedValue<string | null>;
// ... internal methods
} | nullSideIslandProvider
Provider component for managing island state globally.
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| children | React.ReactNode | - | Child components |
| defaultExpanded | boolean | false | Initial expanded state |
| onExpandedChange | (next: boolean) => void | - | Callback when expanded state changes |
| config | SideIslandConfig | - | Default configuration for all islands (see SideIslandConfig below) |
| value | { expanded: boolean; setExpanded: (next: boolean) => void; config?: SideIslandConfig } | - | External state control (for advanced use cases) |
useSideIsland()
Hook to access and control the island state from the provider.
Returns
{
expanded: boolean;
setExpanded: (next: boolean) => void;
open: () => void;
close: () => void;
toggle: () => void;
config: SideIslandConfig;
}SideIslandConfig
Configuration object for default island settings (used in Provider or individual islands).
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| position | "left" \| "right" | "right" | Default position |
| width | number | 40 | Default width |
| height | number | 250 | Default height |
| waveAmplitude | number | 18 | Default wave amplitude |
| waveY1 | number | 0.1 | Default top wave position |
| waveY2 | number | 0.9 | Default bottom wave position |
| backgroundColor | string | "#000000" | Default background color |
| topOffset | number | 0 | Default top offset |
| haptics | SideIslandHaptics | - | Default haptics adapter |
SideIslandHaptics
Haptics adapter interface for providing haptic feedback.
| Property | Type | Description |
|----------|------|-------------|
| onOpen | () => void \| Promise<void> | Called when island opens |
| onClose | () => void \| Promise<void> | Called when island closes |
| onFocusChange | (info: { index: number } \| null) => void \| Promise<void> | Called when focused item changes |
Example with expo-haptics:
import * as Haptics from "expo-haptics";
<SideIsland
haptics={{
onOpen: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
onClose: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
onFocusChange: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Rigid),
}}
// ... other props
/>Running the Example App
The repository includes an example Expo app demonstrating various use cases.
Prerequisites
- Node.js (v18 or later)
- npm or yarn
- Expo CLI (optional, but recommended):
npm install -g expo-cli
Setup
Install dependencies for the main package:
npm installBuild the package:
npm run buildNavigate to the example app:
cd apps/exampleInstall example app dependencies:
npm install
Running
Start the Expo development server:
npm startThis will open the Expo DevTools. From there you can:
- Press
ito open iOS simulator (requires Xcode on macOS) - Press
ato open Android emulator (requires Android Studio) - Scan the QR code with Expo Go app on your physical device
- Press
wto open in web browser
Or use the specific platform commands:
npm run ios # iOS simulator
npm run android # Android emulator
npm run web # Web browserExample App Features
The example app demonstrates:
- People picker with avatar items
- Color picker with label colors
- Focused item detail views
- Backdrop blur effects
- Haptic feedback integration
- Multiple islands (left and right positioned)
TypeScript
The library is written in TypeScript and includes full type definitions. All exports are properly typed:
import {
SideIsland,
SideIslandProvider,
useSideIsland,
type SideIslandProps,
type SideIslandConfig,
type SideIslandController,
type SideIslandPosition,
type SideIslandHaptics,
} from "@peersahab/side-island";License
MIT
