# Portal moves SwiftUI views between screens by overlaying a window above the app

> Portal is a SwiftUI package that animates elements across navigation contexts, flows scroll headers into navigation bars, and can mirror live views through a private Apple API. The transitions and headers modules are usable in a shipping app, while the mirroring module is an explicit App Store risk that the author documents as such.

**Aeastr/Portal** — Portal is a SwiftUI package for element transitions across navigation contexts, scroll-based flowing headers, and advanced view mirroring capabilities.

- Repository: https://github.com/Aeastr/Portal
- Stars: 1,106 · Forks: 40
- Language: Swift
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/aeastr-portal

## Three modules with three different risk levels

Portal is not one library but three, and the differences between them matter more than the shared name suggests. PortalTransitions animates views between navigation contexts such as sheets, navigation stacks and tabs, and it is built on standard SwiftUI APIs. PortalHeaders handles scroll based header transitions that flow into the navigation bar in the style of the Music and Photos apps, and it depends on the advanced scroll tracking APIs introduced in iOS 18. The third module, _PortalPrivate, performs true view mirroring through Apple's private _UIPortalView API.

The target table in the README puts the deployment floors plainly: PortalTransitions needs iOS 17 and up, PortalHeaders needs iOS 18 and up, and _PortalPrivate is the private API variant of the transitions module. The package badge also states Swift 6.2 and above, and iOS and iPadOS 17 and above. So the decision is not one platform version but three, and choosing a module is choosing how much of the framework you are willing to depend on. Two of the three are ordinary SwiftUI. The third talks to Apple internals.

## The overlay window is what makes a cross screen animation possible

A view that lives in a sheet and a view that lives in a navigation stack are in different SwiftUI hierarchies, so a plain matchedGeometryEffect cannot carry one into the other. Portal solves that with a window. PortalTransitions creates a transparent PassThroughWindow that sits above the entire app UI, and source and destination views register their bounds through a PreferenceKey. When a transition is triggered, the view is rendered inside that overlay window and animated between the two recorded positions.

The window does not swallow interaction, which is the part that makes the approach workable in a real app. It uses a custom hitTest implementation that captures touches only on the animated layer itself, and every other touch passes through to the app underneath, so buttons and scroll views keep responding while a transition is in flight. The alternative approach, snapshotting the view into an image and animating the picture, loses interactivity and loses fidelity. That trade-off is exactly what separates PortalTransitions from _PortalPrivate: the public module moves a rendered representation, and the private module moves the real view instance, which is shared rather than recreated.

## Marking the source and the destination is the whole setup

Using PortalTransitions takes four steps. You wrap your app in a PortalContainer, mark the view that leaves the screen with the portal modifier in source position, mark the view that receives it as destination, and then attach the transition modifier to the presentation that triggers it. Here is the sequence exactly as the README writes it.

```swift
PortalContainer {
    ContentView()
}

Image("cover")
    .portal(id: "book", .source)

Image("cover")
    .portal(id: "book", .destination)

.fullScreenCover(item: $selectedBook) { book in
    BookDetail(book: book)
}
.portalTransition(item: $selectedBook)
```

The identifier string is what pairs the two ends, and it has to be the same on the source and the destination. The result is a view that animates from source to destination when the cover is presented and animates back when it is dismissed, and the dismissal animation comes for free because the same pair of marked views is used in both directions. There is no separate API for the return path, which keeps the wiring symmetrical and small.

For flowing headers, the shape is different. The header view is placed inside the scroll content and a destination modifier is applied to the scroll view, while the title and subtitle are attached to the navigation container.

```swift
NavigationStack {
    ScrollView {
        PortalHeaderView()

        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
    .portalHeaderDestination()
}
.portalHeader(title: "Favorites", subtitle: "Your starred items")
```

The header module tracks scroll position with ScrollGeometry and interpolates between the inline and navigation bar states based on content offset thresholds.

## Three levels of control over the animating layer

The transition modifier accepts an optional configuration closure, and the three levels of it are worth understanding because they mark the boundary between convenience and manual layout. With no configuration, frame and offset are handled automatically. A styling closure receives content and an isActive flag and is meant for effects such as clipping and shadows, with the frame and offset still managed for you. The full configuration closure also receives size and position, and from that point you are responsible for the frame and offset yourself, which is the escape hatch for cases where the modifier needs to sit in a particular position in the chain.

```swift
.portalTransition(item: $selectedBook) { book in
    Image("cover")
} configuration: { content, isActive, size, position in
    content
        .frame(width: size.width, height: size.height)
        .clipShape(.rect(cornerRadius: isActive ? 0 : 12))
        .offset(x: position.x, y: position.y)
}
```

The isActive flag is what lets a corner radius animate from rounded to square as the cover settles into its final position. The interesting part of the full closure is that the position it hands you is the one Portal computed from the recorded source and destination bounds, so you keep the geometry logic without reimplementing it. A project that needs unusual clipping or a custom shadow gets that by taking over layout, and the documentation in each module's DocC catalog covers the details.

## The private module is documented as a way to lose an App Store review

The README does not bury this. The _PortalPrivate section carries a warning that the module uses Apple's private _UIPortalView API, that apps using private APIs may be rejected by App Store Review, and that the author assumes no responsibility for App Store rejections or crashes arising from the module. The mirror of that warning is in the mechanism itself: the class names are obfuscated at compile time to avoid detection. A project that depends on detection avoidance cannot be honestly described as App Store safe.

What you gain for that risk is a real difference in behaviour. The same API surface as PortalTransitions is used, but the view instance is shared rather than recreated, so the destination is the live view with its state intact rather than a copy. The README points to a separate repository, UIPortalBridge, for a standalone wrapper of _UIPortalView, which is the more sensible dependency for anyone who only needs the bridging layer.

If you need this behaviour in a shipping product, the honest options are to keep the private module out of App Store builds through separate targets, or to use the public module and accept the snapshot semantics. The obfuscation is compile time only, so it is a code level measure rather than a guarantee of anything, and no amount of obfuscation changes the review outcome.

## Version history tells you what has been changing

The release names describe the direction of travel. Version 6.0.0, released on 2026-01-14, is titled Namespace Scoping, and 6.1.0, released on 2026-08-14, is titled Transition Reliability. Those two titles point at the two areas a shared element animation library tends to break in: name collisions when several transitions exist in one hierarchy, and transitions that misfire under load or during interactive dismissal. The last push was on 2026-09-26, so work continued after the 6.1.0 tag. The project is not archived.

Installation is through Swift Package Manager, and the README shows the dependency declaration starting at version 4.0.0.

```swift
dependencies: [
    .package(url: "https://github.com/Aeastr/Portal.git", from: "4.0.0")
]
```

That floor is worth reading carefully against the badge requirements, since the badges describe the current release line while the dependency snippet is a minimum. For an app that still targets iOS 15 or iOS 16, the README directs you to pin to the v2.1.0 tag or use the legacy/ios15 branch, so a modern version of the package is not available to you without going back to the older line. The package is MIT licensed, with the licence text in LICENSE. The build and test workflows are wired through GitHub Actions, and the repository carries a.swiftlint.yml, a CONTRIBUTING guide and per module DocC catalogs.

## Conclusion

Use PortalTransitions and PortalHeaders if your team wants shared element style animations without building an overlay system of its own, and check the deployment target first because the two modules need iOS 17 and iOS 18 respectively. Skip the _PortalPrivate module in any app you intend to submit to the App Store, since the project itself warns that private API use may lead to rejection. Before adopting, confirm that your views expose the bounds Portal needs through its preference mechanism and that the destination view is present when the transition is triggered.

## FAQ

### What iOS version does Portal require?

It depends on the module. PortalTransitions requires iOS 17 or later, PortalHeaders requires iOS 18 or later because it uses the newer scroll tracking APIs, and the package badge states iOS and iPadOS 17 and above with Swift 6.2 and above.

### How do I install Portal in a SwiftUI project?

Add the Swift Package Manager dependency shown in the README, pointing at https://github.com/Aeastr/Portal.git from version 4.0.0, then depend on the PortalTransitions, PortalHeaders or _PortalPrivate product depending on the behaviour you need.

### Can I submit an app that uses the _PortalPrivate module to the App Store?

The project states plainly that it uses Apple's private _UIPortalView API and that apps using private APIs may be rejected by App Store Review, with no responsibility taken for rejections or crashes. It also obfuscates class names at compile time to avoid detection.

### What is the difference between PortalTransitions and _PortalPrivate?

Both expose the same API, but PortalTransitions renders the view in an overlay window while _PortalPrivate uses Apple's private _UIPortalView so the actual view instance is shared rather than recreated, which keeps live state intact.

### How does Portal keep buttons working during a transition?

The overlay PassThroughWindow uses a custom hitTest implementation that captures touches only on the animated layer itself, and every other touch passes through to the app below.

## Sources

- [Aeastr/Portal on GitHub](https://github.com/Aeastr/Portal)
- [Issues](https://github.com/Aeastr/Portal/issues)
- [License: MIT](https://github.com/Aeastr/Portal/blob/main/LICENSE)
- [README](https://github.com/Aeastr/Portal/blob/main/README.md)
- [Releases](https://github.com/Aeastr/Portal/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/aeastr-portal
