I Turned My Navbar Into a Dynamic Island
How I turned my navbar into a Dynamic Island, from spring physics and glass masking to reading progress, feedback states, testing, and accessibility.
I wanted a small circle to separate from my navbar when someone reached the end of an article. Inside that circle, a green tick would appear. After a moment, the circle would return to the navbar.
My navbar already expanded and contracted. It already had a mobile drawer, an audio player, and a reading-progress outline on writings and dev notes. I liked that design. I wanted to give it another useful behavior without losing what already worked.
The finished version does more than acknowledge the end of an article. It can split out a copied-link confirmation or expand to show contact-form progress, success, failure, and code or RSS-copy feedback. It still feels like part of the same website.
Getting there involved two reference repositories, several rounds of testing in the browser, a floating table of contents that I removed, and a surprisingly persistent semicircle. I built it iteratively with GPT 6 Astra in Codex: I brought the references, tried the implementation in the dev server, pointed out what felt wrong, and kept asking for the whole interaction to be checked again after each fix.
This post is that journey, including the parts that did not survive.
The examples below are real React animations. The split and expanded-feedback previews reuse components from the shipped navbar. Their controls run locally inside the article. The progress sliders and message outcomes are demonstrations: they do not submit a form, copy a link, or alter the real navbar's state. The real navbar still follows your actual position in this article.
What I wanted to keep
Before adding anything, I had to be clear about what was already valuable.
At the top of a page, my navbar gives its links more room. After scrolling, it becomes a smaller pill. Hovering on a laptop can reveal the expanded navigation again. On mobile, a menu opens beneath it. Audio playback can take over the available space without requiring another permanent player on the screen.
On article pages, a gradient travels around the navbar as reading progresses. The outline is drawn as two SVG paths. Each starts at the top center, follows one side of the pill, and ends at the bottom center.
These behaviors were already connected. Replacing the navbar wholesale would have meant rebuilding and retesting all of them. I decided to add a small number of states around the existing structure.
My constraints were concrete:
- Keep the current expand/contract behavior and mobile drawer.
- Preserve the audio controls and their state.
- Keep Relative typography, the dark surfaces, and the site's existing spacing.
- Make the split work at narrow mobile widths as well as on a laptop.
- Keep navigation usable before, during, and after a confirmation.
- Let temporary feedback disappear without requiring a reload.
- Respect reduced-motion preferences.
I also wanted the motion to come from a reference I could inspect. A screenshot can show a destination, but it cannot explain the spring, the delay before an icon appears, or the way a connecting shape disappears.
The two repositories I started from
I asked for both codebases to be cloned, rather than relying on a visual approximation from memory:
1gh repo clone amelie-schlueter/dynamic-island-web
2gh repo clone jhaemin/dynamic-islandThey helped in different ways.
| Reference | What I studied | What carried into the build |
|---|---|---|
| Amélie Schlüter's dynamic-island-web | A compact React implementation with a layout-animated island and different content states | The treatment of the island as a changing surface, plus an early top-origin layout morph during the contents-panel experiment |
| Jhaemin's dynamic-island | Split geometry, spring configurations, blur timing, and a larger expanded state | The liquid separation, its 42px travel, and the motion values behind the expanded confirmation |
Amélie's implementation uses Framer Motion. Jhaemin's uses react-spring. My site already had framer-motion, so I kept the existing dependency and translated the reference's spring configuration into that system.
I did not bring over the phone-call content, caller photos, app icons, or demo fonts. Those belonged to the reference experience. My content was article completion, copying, and contact feedback.
That distinction mattered throughout the build: I could preserve the motion's geometry and timing while making the resulting interface belong to my website.
I also had to be honest about precision. Matching spring parameters across two animation engines is not a guarantee that every frame will be identical. I preserved the relevant source values, then checked the result in the browser.
The split is more than a circle moving sideways
The detail I wanted was the brief connection between the main pill and the smaller shape. It stretches, narrows, and breaks as the satellite moves away.
Jhaemin's implementation builds that effect with a small SVG. Its original coordinate space is 90 by 52. An anchor circle sits near the main island, and a rounded rectangle moves horizontally to become the detached satellite.
The reference values were small enough to understand directly:
| Part | Value |
|---|---|
| SVG coordinate space | 90 × 52 |
| Anchor center | 26, 26 |
| Anchor radius | 15 |
| Satellite size | 36 × 32 |
| Satellite corner radius | 16 |
| Horizontal travel | 42px |
| Blur at the joined state | 10 |
| Delay before sharpening | 250ms |
| Sharpening duration | 400ms |
The satellite is slightly wider than it is tall. I kept that geometry instead of silently replacing it with a mathematically perfect circle.
Here is the basic arrangement, before the filtering and glass-edge masking are added:
1<svg width="90" height="52" viewBox="0 0 90 52">
2 <circle cx="26" cy="26" r="15" fill="black" />
3 <motion.rect
4 x="6"
5 y="10"
6 width="36"
7 height="32"
8 rx="16"
9 fill="black"
10 style={{ x: offset }}
11 />
12</svg>The SVG is positioned partly over the edge of the navbar. Its internal x coordinate is therefore not the same as the page's horizontal position. Understanding those two coordinate systems became essential when I fixed the overlap later.
How the liquid connection works
The two shapes are blurred together, then their alpha channel is pushed through a color matrix. Blurring creates a soft overlap. The matrix turns part of that overlap back into a firmer silhouette.
The relevant filter looks like this:
1<filter
2 id={filterId}
3 width="400%"
4 x="-150%"
5 height="400%"
6 y="-150%"
7>
8 <motion.feGaussianBlur
9 in="SourceGraphic"
10 result="blur"
11 initial={{ stdDeviation: 10 }}
12 animate={{ stdDeviation: open ? 0 : 10 }}
13 transition={{
14 duration: open ? 0.4 : 0.1,
15 delay: open ? 0.25 : 0,
16 }}
17 />
18 <feColorMatrix
19 in="blur"
20 type="matrix"
21 values="1 0 0 0 0
22 0 1 0 0 0
23 0 0 1 0 0
24 0 0 0 25 -10"
25 />
26</filter>The final row is the interesting one. In this matrix, the new alpha is calculated from 25 × alpha - 10, with the output constrained to its valid range. That steep change makes a blurred connection look much more defined. The first three rows leave the color channels unchanged. MDN documents how the color matrix transforms each channel.
I kept the filter region larger than the original shapes so the blur had room to extend. Later, I added a separate clip to control where the finished effect could actually paint. Those two operations serve different purposes: the filter needs space to calculate the shape; the interface needs boundaries around where that shape is visible.
Tuning the movement without inventing a new animation
I started with the reference's spring values instead of choosing an arbitrary duration.
In the port, react-spring tension became Motion stiffness, friction became damping, and mass stayed mass:
1export const splitSpring = {
2 type: "spring" as const,
3 stiffness: 250,
4 damping: 26,
5 mass: 2,
6};
7
8export const mergeSpring = {
9 type: "spring" as const,
10 stiffness: 300,
11 damping: 26,
12 mass: 0.1,
13};The outward split feels weighted. The return is much quicker. That difference is useful: the outward movement introduces something worth noticing, while the return clears space so I can continue reading.
I find spring values easier to understand by changing one at a time. In the demo, stiffness controls how strongly the shape is pulled toward its destination. Damping removes oscillation. Mass changes the response of the moving shape.
The initial values match the shipped split. The controls below are a local experiment; changing them does not retune the real navbar.
I did not expose these settings on the actual website. Readers should get one considered animation. The sliders belong in an explanation like this one, where seeing the consequences is useful.
My first big mistake: the tick arrived before its circle
One early version looked acceptable when I inspected only its final state. The circle was in the right place. The tick was centered. The spacing was reasonable.
Watching it move told a different story.
The tick appeared at the destination while the background shape was still coming out of the navbar. For a moment, the icon looked detached from the thing that was supposed to contain it.
I initially had separate decisions for the circle and the icon: one spring moved the SVG, while another set of animation properties revealed the tick. Matching delays made it less obvious, but did not give the two elements a single source of position.
The fix was to share the moving value.
1const offset = useMotionValue(0);
2const iconOffset = useTransform(offset, value => value - 42);
3
4useEffect(() => {
5 const animation = animate(
6 offset,
7 open ? 42 : 0,
8 reducedMotion
9 ? { duration: 0 }
10 : open
11 ? splitSpring
12 : mergeSpring,
13 );
14
15 return () => animation.stop();
16}, [offset, open, reducedMotion]);The SVG uses offset. The icon uses the same value, adjusted for the position of its wrapper:
1<motion.rect style={{ x: offset }} />
2<motion.span style={{ x: iconOffset }}>
3 <Check />
4</motion.span>The subtraction is a coordinate correction, not an additional animation. The icon's wrapper is already placed at the detached position. Subtracting 42 makes it travel from the navbar edge to that position alongside the satellite.
Motion values can update rendered styles without triggering a React render for every frame, and the same value can drive multiple elements. That made them a good fit for this relationship. Motion's documentation explains shared and derived motion values.
Try the problem mode below. It deliberately places the tick at its destination too early. Then apply the fix and replay the split.
I still animate the icon's opacity separately. It begins appearing after 250ms and fades in over 400ms, alongside the background sharpening. Position, however, has one clock. That is what keeps the icon inside the moving shape when the animation starts, reverses, or is interrupted.
A temporary confirmation needs an exit
The next problem was obvious once I used the page normally: the confirmation stayed there until I reloaded.
I had implemented the arrival more carefully than the departure.
I needed two distinct pieces of state:
- Whether a confirmation is currently active.
- Whether the animation's elements still need to be mounted while they return.
If I removed the SVG the moment the confirmation ended, there would be no merge animation. If I left it mounted indefinitely, I had more opportunities for a faint residual shape to remain visible.
The final sequence is:
- Mount the split elements and show the confirmation.
- Keep the confirmation active for 2.4 seconds.
- Change the target back to the joined position.
- Fade the returning elements.
- Remove the SVG after a 400ms return window.
This shortened excerpt shows the distinction:
1setHasSplit(true);
2setConfirmation(kind);
3
4closeTimer = setTimeout(() => {
5 setConfirmation(null); // starts the merge
6
7 removeTimer = setTimeout(() => {
8 setHasSplit(false); // removes the returned SVG
9 scheduleProgressCheck();
10 }, 400);
11}, 2400);Before starting another confirmation, I clear both timers. Repeated copies should restart the visible confirmation, not leave an older timer waiting to remove a newer one.
I also clear timers and observers when the article component unmounts. The article island is keyed by pathname, so visiting another article starts with that article's own state.
The semicircle that survived the first fixes
Even after automatic retraction worked, I could sometimes see a small semicircle near the navbar's edge. During the return, it could also appear to pass over the Connect button.
There were several overlapping issues, and treating them as a single timing problem did not solve them.
First, the SVG filter covered more area than the visible satellite. Second, its anchor was allowed to paint inside the main pill. Third, my navbar was translucent glass, while the SVG silhouette was solid black.
That last detail explained why matching the nominal background color was not enough.
The navbar's appearance depends on the content behind it. A solid patch painted over that surface changes the result. I could see the difference as a dark semicircle moving into the glass.
I first tightened the effect's bounds and placed it behind the navigation controls. I then made the boundary stricter: the solid split effect could paint only outside the navbar's glass surface.
1<clipPath id={edgeClipId}>
2 <rect x="42" y="0" width="48" height="52" />
3</clipPath>
4
5<g clipPath={`url(#${edgeClipId})`}>
6 <g filter={`url(#${filterId})`}>
7 {/* anchor and moving satellite */}
8 </g>
9</g>Why 42? The 90px-wide SVG sits 48px beyond the navbar's right edge. Its left edge is therefore 42px inside the navbar. In the SVG's coordinate space, x = 42 is the navbar boundary.
I apply another clip to the satellite wrapper, which also contains the icon:
1.reading-completion {
2 position: absolute;
3 right: 0;
4 top: 50%;
5 width: 0;
6 height: 32px;
7 transform: translateY(-50%);
8 clip-path: inset(-10px -60px -10px 0);
9}The negative insets leave room outside the navbar. The zero left inset prevents the returning content from painting across the glass interior. The effect also sits behind navigation content, and the entire SVG is removed after the merge.
The following comparison uses the same split primitive. Problem mode removes the protective clipping in this local preview so the solid shape can paint over the glass again. Watch the return as well as the arrival.
I did not turn the SVG into a physically identical glass material. I stopped it from painting over the glass in the first place. That was the boundary the design needed.
The table of contents I built and then removed
My original idea included another detached shape below the navbar. When an article had well-formed headings, it would show the current section and expand into a table of contents.
I supplied a recording and screenshots showing the kind of compact reading navigation I had in mind. The first implementation discovered heading anchors, followed the current section, allowed section jumps, bounded the list to the viewport, and closed after a selection on mobile.
It was functional. I still did not like the result on my website.
The floating panel added another persistent surface above the article. Expanded, it covered too much of what I was reading. It also changed the visual balance of a navbar I had explicitly wanted to preserve.
I asked for it to be removed.
That decision belongs in this story because it was part of making the feature better. Passing interaction tests did not make the panel the right product choice. I had to use it in context and decide whether I wanted it there.
The final navbar does not have a floating table of contents. The pre-existing article-footer contents disclosure remains part of the article layout. I did not need a second persistent navigation surface above the prose.
Defining what completion actually means
The green tick is a scroll-position acknowledgment. It cannot know whether someone read, understood, or agreed with the article.
For this interface, completion means the reader has reached the end of the article's measured range. I wanted that range to end with the article, rather than requiring a trip through the entire site footer.
I extracted a shared progress calculation so the outline and the completion feedback could agree:
1export function getReadingProgress(article: HTMLElement): number {
2 const scrollY = window.scrollY;
3 const viewport = window.innerHeight;
4 const maxScroll = Math.max(
5 0,
6 document.documentElement.scrollHeight - viewport,
7 );
8 const rect = article.getBoundingClientRect();
9 const start = Math.max(0, rect.top + scrollY - 80);
10 const seam = Math.min(100, Math.max(50, viewport * 0.1));
11 const end = Math.min(
12 maxScroll,
13 rect.bottom + scrollY - viewport + seam,
14 );
15
16 if (end <= start + 1) {
17 return rect.bottom <= viewport ? 1 : 0;
18 }
19
20 return Math.max(0, Math.min(1, (scrollY - start) / (end - start)));
21}The offsets account for the floating navigation and the point at which the article's bottom is considered reached. The end is clamped to the page's maximum scroll position so the target remains reachable. Compact articles get a separate check instead of a division by a tiny range.
This measures the marked article frame, which includes the article's own footer controls. It is a UI definition of progress, and I keep that definition consistent rather than pretending it measures attention.
Scroll events are passive and scheduled through requestAnimationFrame. Resize observers let the calculation respond when the layout changes. This matters when images load, the viewport changes size, or another article element changes height.
The outline needed to hand over to the tick
At first, reaching the end left the full gradient around the navbar while the tick appeared beside it. I wanted the completion acknowledgment to take over instead of competing with the completed outline.
Related projects
Managing Environment Variables Securely with Keycheck
Keycheck compares your local env file with a project template in the browser, helping you catch missing, stale, empty, and placeholder variables securely.
OpenIntroducing the Human Interface Guidelines Compliance Auditor for Claude Code
Audit and align your codebase with Apple design standards using an automated tool that scans for violations and provides direct remediation options.
OpenI Built AdaptiveKit to Give Any Web App a UI That Learns From the People Using It
AdaptiveKit is an open-source toolkit that adds behavioral UI personalization to any React or Next.js app in under ten minutes, with zero hosted backend.
Open