MorphModalKit
Simple Morphable, Stackable Modals
Install / Use
/learn @jsmmth/MorphModalKitREADME
MorphModalKit is a lightweight, flexible UIKit package for building card-stack modals with smooth “morph” (replace) animations and support for sticky elements. It provides a blank-canvas container—feel free to use your own views and components.
If you are looking to use SwiftUI you can checkout the SwiftUI docs although this SwiftUI implementation is experimental and for a more stable approach I'd recommend using UIKit for this modal system.
<p align="center"> <img width="100%" src="./example.gif" alt="MorphModalKit"> </p>🚀 Installation
Swift Package Manager
- In Xcode, choose File ▶ Add Packages…
- Enter this URL:
https://github.com/jsmmth/MorphModalKit.git - Click Add Package, select your target(s), and finish.
Package.swift
If you manage dependencies via a manifest:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v15)],
dependencies: [
.package(url: "https://github.com/jsmmth/MorphModalKit.git", from: "0.0.1"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "MorphModalKit", package: "MorphModalKit")
]
)
]
)
Then in your code:
import MorphModalKit
🎨 Core Concepts
ModalView
Conform your UIViewController to ModalView:
public protocol ModalView: UIViewController {
/// Desired height for a given container width
func preferredHeight(for width: CGFloat) -> CGFloat
/// Whether the modal can be dismissed by swipe or tapping overlay
var canDismiss: Bool { get }
/// If returning a UIScrollView, its top-bounce gesture will dismiss the modal
var dismissalHandlingScrollView: UIScrollView? { get }
// Optional lifecycle hooks
func modalWillAppear(fromReplaced: Bool)
func modalDidAppear(fromReplaced: Bool)
func modalWillDisappear(beingReplaced: Bool)
func modalDidDisappear(beingReplaced: Bool)
}
Minimal example:
class ExampleModal: UIViewController, ModalView {
override func viewDidLoad() {
super.viewDidLoad()
// Build your UI…
}
func preferredHeight(for _: CGFloat) -> CGFloat { 320 }
}
Tip: The container will never exceed the device height; it adapts when the keyboard appears. Wrap content in a scroll view and return it in
dismissalHandlingScrollViewto enable pull-to-dismiss from the scroll gesture.
📱 Presenting & Navigation
All presentation APIs live on any UIViewController once you import MorphModalKit.
Presenting
presentModal(
ExampleModal(),
options: ModalOptions.default,
sticky: StickyOption = .none,
animated: true,
showsOverlay: true
)
sticky(optional): supply a subclass ofStickyElementsContainerusing.sticky(MySticky.self)to render persistent UI which sticks around during morph (replace) animations. Allowing you to have a view which acts as a container of shared elements between replace calls.options: adjust layout, shadows, animation springs, and more (see Configuration below).
Pushing & Popping
Within a ModalView, you can retrieve the host controller:
modalVC?.push(AnotherModal()) // new modalView added to the stack
modalVC?.pop() // back to previous card
modalVC?.hide() // dismiss the entire stack
When you push a new modal to the stack you can also provide sticky param (defaults to .none) to either inherit the previous modals sticky elements, provide a new sticky with .sticky(MySticky.self) element or set it to .none.
Replace (Morph)
Swap the content of the top card without moving the card itself:
modalVC?.replace(
with: NextModal(),
direction: .forward, // or .backward
animation: .scale // or .slide(100)
)
.scale(default): fades between views, scaling from 95–105%..slide(px): both incoming and outgoing views slide horizontally bypx.
📌 StickyElementsContainer
Use a StickyElementsContainer subclass to overlay persistent UI (e.g., headers, footers, navigation bars) across replace animations.
class MySticky: StickyElementsContainer {
required init(modalVC: ModalViewController) {
super.init(modalVC: modalVC)
// add subviews & constraints…
}
required init?(coder: NSCoder) { fatalError() }
override func contextDidChange(
to newOwner: ModalView,
from oldOwner: ModalView?,
animated: Bool
) {
// update state when the modal content changes
}
}
Pass your sticky class to present(…, sticky:) or push(…, sticky:).
Tip: See Examples/UIKitExample/Modals/StickyElements.swift for an example of how I have used this in the example.
Note: Interaction events “fall through” the container except on its interactive subviews.
⚙️ Configuration
Customize every aspect via ModalOptions:
| Property | What it does | Default |
|----------------------------|-----------------------------------------------------------------------------|-----------------------------------|
| horizontalInset | Side margins of each card | 10 |
| bottomSpacing | Space from bottom (nil = safe area + 10) | nil |
| stackVerticalSpacing | Gap between stacked cards | 20 |
| keyboardSpacing | Gap between the bottom of the card and the keyboard | 10 |
| handlebarWidth | Width of the handlebar | 52 |
| handlebarHeight | Height of the handlebar | 4 |
| keyboardSpacing | Gap between the bottom of the card and the keyboard | 10 |
| cornerRadius | Card corner radius | 32 |
| innerCornerRadius | Card corner radius for inner wrapper (useful for top radius being unique) | nil |
| innerCornerMask | Card corner mask for inner wrapper | nil |
| cornerMask | Card corner mask | [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] |
| maxVisibleStack | How many cards peek behind the front card | 2 |
| dimBackgroundColor | Color of background cards | .black |
| dimOpacityMultiplier | Darkness of background cards | 0.06 |
| overlayColor | Overlay backdrop color | .black |
| overlayOpacity | Overlay backdrop opacity | 0.2 |
| modalBackgroundColor | Base card background | .secondarySystemGroupedBackground |
| animation | Spring settings for present/push/pop | (0.4, damping:0.86, velocity:0.8) |
| morphAnimation | Spring settings for replace (morph) | (0.4, damping:0.95, velocity:1) |
| cardShadow | Card shadow (color, opacity, radius, offset) | (.black, 0.12, 9, (0,2)) |
| usesSnapshots | Snapshot offscreen cards for performance | true |
| usesSnapshotsForMorph | Snapshot during morph replacements | false |
| showsHandle | Show drag-handle when dismissable | true |
| handleColor | Color of the drag-handle | .tertiarySystemGroupedBackground |
| enableGlass | Enables liquid glass background effect (iOS 26 only) | false |
| glassStyle | When enableGlass is true allows you to change glass style (iOS 26 only) | .regular |
| centerOnIpad | Whether ot not the modal should be centered on iPad or not | true |
| centerIPadWidthMultiplier| Width of the modal when centered on iPad | 0.7 |
Example: Full-width, bottom-pinned modal
var opts = ModalOptions.default
opts.horizontalInset = 0
opts.bottomSpacing = 0
opts.cornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
presentModal(MyModal(), options: opts, sticky: MySticky.self)
🔍 Tips & Tricks
- Performance: Background cards snapshot themselves for smooth animations. Disable via
options.usesSnapshots = false. - Keyboard Avoidance: The stack re-layouts on keyboard frame changes, maintaining `keyboardSpac
