A highly customizable, gesture-driven side menu component for SwiftUI with full accessibility support.
- Fluid Gesture - Velocity-based spring animations with rubber band effect at menu edges
- Both Edges - Mount on the leading or trailing edge, or use
DualSideMenuViewfor both at once - 3 Presentation Styles - Slide-in-over, slide-in-out, and slide-out (Threads-like) + custom layout
- Gesture Control - Full-screen or edge-only drag activation with configurable sensitivity
- Haptic Feedback - Configurable haptic feedback on open/close and rubber band limit
- Visual Effects - Per-style blur, scale, and dim effects for natural transitions
- Accessibility - Complete VoiceOver support with focus management and escape actions
- Type Safe - Fully documented public API with Swift 6 concurrency support
- iOS 17.0+
- Swift 6.0+
- Xcode 16.0+
Add SideMenu to your project via Xcode:
- File > Add Package Dependencies...
- Enter the repository URL:
https://github.com/shima11/SideMenu - Select the version you want to use
Or add it to your Package.swift file:
dependencies: [
.package(url: "https://github.com/shima11/SideMenu", from: "0.1.0")
]import SwiftUI
import SideMenu
struct ContentView: View {
@State private var menuState = SideMenuState()
var body: some View {
SideMenuView(model: menuState) {
// Side menu content
List {
Button("Home") { withAnimation { menuState.close() } }
Button("Settings") { withAnimation { menuState.close() } }
}
} mainView: {
NavigationStack {
Text("Main Content")
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Menu", systemImage: "line.3.horizontal") {
withAnimation { menuState.toggle() }
}
}
}
}
}
}
}For a complete interactive example with all configuration options, check out the Demo app in the repository.
Menu slides over the main content, which remains in place.
SideMenuConfiguration(
menuStyle: .slideInOver(blur: 3, scale: 0.95, dimValue: 0.3)
)| Parameter | Default | Description |
|---|---|---|
blur |
2 |
Blur radius applied to main content |
scale |
1 |
Scale factor applied to main content |
dimValue |
0.2 |
Dim overlay opacity |
Menu and main content slide together.
SideMenuConfiguration(
menuStyle: .slideInOut(dimValue: 0.2)
)| Parameter | Default | Description |
|---|---|---|
dimValue |
0.2 |
Dim overlay opacity |
Main content slides out to reveal the menu underneath (like Meta Threads).
SideMenuConfiguration(
menuStyle: .slideOut(scale: 0.9, dimValue: 0.2, backgroundColor: .systemBackground)
)| Parameter | Default | Description |
|---|---|---|
scale |
0.9 |
Scale factor for the menu |
dimValue |
0.2 |
Dim overlay opacity |
backgroundColor |
nil |
Background color behind the menu during scale animation |
Fully custom layout with user-defined closures.
SideMenuConfiguration(
menuStyle: .custom(
dimValue: 0.2,
sideMenuLayout: { context, menuView in
// Custom menu layout
},
mainViewLayout: { context, mainView in
// Custom main view layout
}
)
)let config = SideMenuConfiguration(
menuWidth: 0.7,
menuStyle: .slideInOver(blur: 3, scale: 0.95, dimValue: 0.3),
menuAnimation: .spring(duration: 0.4, bounce: 0.2),
dragActivation: .edge(edgeWidth: 30),
hapticStyle: .medium
)
SideMenuView(model: menuState, configuration: config) {
MenuView()
} mainView: {
MainContentView()
}| Property | Type | Default | Description |
|---|---|---|---|
menuWidth |
CGFloat |
0.8 |
Width of menu as fraction of screen (0.0 to 1.0) |
menuStyle |
MenuStyle |
.slideInOut() |
Presentation style |
menuAnimation |
Animation |
.spring(duration: 0.4, bounce: 0.0) |
Animation curve for transitions |
dragActivation |
MenuDragActivation |
.full() |
Drag activation area |
hapticStyle |
FeedbackStyle? |
.medium |
Haptic feedback style (nil to disable) |
edge |
MenuEdge |
.leading |
Which screen edge the menu appears from |
velocityThreshold |
CGFloat |
300 |
Minimum flick velocity (pt/s) to trigger open/close |
rubberBandLimit |
CGFloat |
40 |
Maximum rubber band displacement in points |
Choose which screen edge the menu appears from. Drag direction, layout, and edge-only activation zone all flip automatically.
SideMenuConfiguration(edge: .trailing)| Value | Description |
|---|---|
.leading |
Menu appears from the leading edge (left in LTR, right in RTL). Default. |
.trailing |
Menu appears from the trailing edge (right in LTR, left in RTL). |
Control how users can open the menu with gestures.
// Full-screen drag (default)
.full(startThreshold: 15, openCloseThreshold: 50, directionRatio: 1.5)
// Edge-only drag
.edge(edgeWidth: 24, startThreshold: 15, openCloseThreshold: 50, directionRatio: 1.5)| Parameter | Default | Description |
|---|---|---|
edgeWidth |
24 |
Width of edge drag area in points (edge only) |
startThreshold |
15 |
Minimum horizontal drag distance to start gesture |
openCloseThreshold |
50 |
Minimum drag distance to open/close |
directionRatio |
1.5 |
Required horizontal/vertical ratio (higher = stricter) |
// Open the menu
withAnimation { menuState.open() }
// Close the menu
withAnimation { menuState.close() }
// Toggle the menu
withAnimation { menuState.toggle() }
// Check if menu is open
if menuState.isOpen { /* ... */ }DualSideMenuView mounts menus on both edges simultaneously with type-level mutual exclusion: at most one side can be open at a time, enforced by the state enum itself.
import SwiftUI
import SideMenu
struct ContentView: View {
@State private var menuState = DualSideMenuState()
var body: some View {
DualSideMenuView(model: menuState) {
// Leading menu
LeadingMenu()
} trailingMenu: {
// Trailing menu
TrailingMenu()
} mainView: {
NavigationStack {
MainContent()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Menu", systemImage: "line.3.horizontal") {
withAnimation { menuState.toggle(.leading) }
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Activity", systemImage: "bell") {
withAnimation { menuState.toggle(.trailing) }
}
}
}
}
}
}
}DualSideMenuState.currentState is case closed or case open(MenuEdge). Opening one side automatically replaces any other open side — both menus can never be open at the same time.
menuState.openLeading() // .open(.leading)
menuState.openTrailing() // .open(.trailing) — closes leading if it was open
menuState.toggle(.leading) // toggle a specific side
menuState.close() // .closed
menuState.isOpen // any side open?
menuState.openEdge // MenuEdge? — which side, if anyDualSideMenuConfiguration mirrors SideMenuConfiguration but omits the edge field (both edges are always rendered).
| Property | Type | Default | Description |
|---|---|---|---|
menuWidth |
CGFloat |
0.8 |
Width of each menu as a fraction of screen (shared by both sides) |
menuStyle |
MenuStyle |
.slideInOut() |
Presentation style. .custom is not supported and falls back to .slideInOver |
menuAnimation |
Animation |
.spring(duration: 0.4, bounce: 0.0) |
Animation curve for transitions |
dragActivation |
MenuDragActivation |
.full() |
Drag activation area. In .edge mode, leading edge opens leading and trailing edge opens trailing |
hapticStyle |
FeedbackStyle? |
.medium |
Haptic feedback style (nil to disable) |
velocityThreshold |
CGFloat |
300 |
Minimum flick velocity (pt/s) to trigger open/close |
- From
.closed: drag right opens leading, drag left opens trailing. In.edgemode the start zone determines the side. - From
.open(.leading): only the closing direction is accepted. - From
.open(.trailing): only the closing direction is accepted. - Sides cannot be swapped within a single drag — release and re-drag to switch.
SideMenu includes comprehensive accessibility support:
- VoiceOver - Proper focus management between menu and main content
- Escape Action - VoiceOver users can close the menu with escape gesture
- Modal Trait - Menu is announced as modal when open
- Focus Management - Automatic focus transfer when menu opens/closes
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
Implementation inspired by this Medium article.