I Turned My Navbar Into a Dynamic Island

Building a Dynamic Island into my navbar with GPT 6 Astra in Codex. A complete journey through spring physics, glass masking, and state orchestration, with interactive demos and reference code.

26 min read

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.

Note: The interactive demos below use real SVG filters, spring physics, and dynamic backdrop blurring. These interactions are GPU-intensive and are optimized to perform best on Apple silicon and Safari, where hardware-accelerated compositing handles multiple glass surfaces effortlessly.

Interactive / split

A small piece of the navbar, set free

This uses the real split primitive, so you can replay it or interrupt it as it returns.
Ready to try

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:

gh repo clone amelie-schlueter/dynamic-island-web
gh repo clone jhaemin/dynamic-island

They helped in different ways.

ReferenceWhat I studiedWhat carried into the build
Amélie Schlüter's dynamic-island-webA compact React implementation with a layout-animated island and different content statesThe treatment of the island as a changing surface, plus an early top-origin layout morph during the contents-panel experiment
Jhaemin's dynamic-islandSplit geometry, spring configurations, blur timing, and a larger expanded stateThe 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:

PartValue
SVG coordinate space90 × 52
Anchor center26, 26
Anchor radius15
Satellite size36 × 32
Satellite corner radius16
Horizontal travel42px
Blur at the joined state10
Delay before sharpening250ms
Sharpening duration400ms

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:

<svg width="90" height="52" viewBox="0 0 90 52">
  <circle cx="26" cy="26" r="15" fill="black" />
  <motion.rect
    x="6"
    y="10"
    width="36"
    height="32"
    rx="16"
    fill="black"
    style={{ x: offset }}
  />
</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:

<filter
  id={filterId}
  width="400%"
  x="-150%"
  height="400%"
  y="-150%"
>
  <motion.feGaussianBlur
    in="SourceGraphic"
    result="blur"
    initial={{ stdDeviation: 10 }}
    animate={{ stdDeviation: open ? 0 : 10 }}
    transition={{
      duration: open ? 0.4 : 0.1,
      delay: open ? 0.25 : 0,
    }}
  />
  <feColorMatrix
    in="blur"
    type="matrix"
    values="1 0 0 0 0
            0 1 0 0 0
            0 0 1 0 0
            0 0 0 25 -10"
  />
</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:

export const splitSpring = {
  type: "spring" as const,
  stiffness: 250,
  damping: 26,
  mass: 2,
};
 
export const mergeSpring = {
  type: "spring" as const,
  stiffness: 300,
  damping: 26,
  mass: 0.1,
};

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.

Interactive / spring

Feel the spring

The starting values match the shipped split, and you can change them to feel the difference.
Ready to try

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.

const offset = useMotionValue(0);
const iconOffset = useTransform(offset, value => value - 42);
 
useEffect(() => {
  const animation = animate(
    offset,
    open ? 42 : 0,
    reducedMotion
      ? { duration: 0 }
      : open
        ? splitSpring
        : mergeSpring,
  );
 
  return () => animation.stop();
}, [offset, open, reducedMotion]);

The SVG uses offset. The icon uses the same value, adjusted for the position of its wrapper:

<motion.rect style={{ x: offset }} />
<motion.span style={{ x: iconOffset }}>
  <Check />
</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.

Interactive / timing

One position, two elements

Compare a tick placed at its destination with a tick that travels with the circle.
Ready to try

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:

  1. Mount the split elements and show the confirmation.
  2. Keep the confirmation active for 2.4 seconds.
  3. Change the target back to the joined position.
  4. Fade the returning elements.
  5. Remove the SVG after a 400ms return window.

This shortened excerpt shows the distinction:

setHasSplit(true);
setConfirmation(kind);
 
closeTimer = setTimeout(() => {
  setConfirmation(null); // starts the merge
 
  removeTimer = setTimeout(() => {
    setHasSplit(false);  // removes the returned SVG
    scheduleProgressCheck();
  }, 400);
}, 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.

<clipPath id={edgeClipId}>
  <rect x="42" y="0" width="48" height="52" />
</clipPath>
 
<g clipPath={`url(#${edgeClipId})`}>
  <g filter={`url(#${filterId})`}>
    {/* anchor and moving satellite */}
  </g>
</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:

.reading-completion {
  position: absolute;
  right: 0;
  top: 50%;
  width: 0;
  height: 32px;
  transform: translateY(-50%);
  clip-path: inset(-10px -60px -10px 0);
}

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.

Interactive / glass

The seam I kept seeing

The striped backdrop makes solid paint over the glass easier to spot.
Ready to try

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:

export function getReadingProgress(article: HTMLElement): number {
  const scrollY = window.scrollY;
  const viewport = window.innerHeight;
  const maxScroll = Math.max(
    0,
    document.documentElement.scrollHeight - viewport,
  );
  const rect = article.getBoundingClientRect();
  const start = Math.max(0, rect.top + scrollY - 80);
  const seam = Math.min(100, Math.max(50, viewport * 0.1));
  const end = Math.min(
    maxScroll,
    rect.bottom + scrollY - viewport + seam,
  );
 
  if (end <= start + 1) {
    return rect.bottom <= viewport ? 1 : 0;
  }
 
  return Math.max(0, Math.min(1, (scrollY - start) / (end - start)));
}

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.

The solution is small:

svgRef.current.style.opacity = progress >= 1 ? "0" : "1";

The SVG fades over 200ms. Scrolling back into the article restores it, and the path offsets continue to reflect the current position.

The progress demo uses a simpler single-color outline to make the state change easier to see. The shipped navbar retains its original two-sided gradient path.

Interactive / progress

Reading has a return journey

Move the slider to 100%, back below 95%, then to the end again.
Ready to try38%

Move to 100%, wait for the tick to return, move below 95%, and then reach the end again. That second pass was another behavior I had to fix.

Completion should be repeatable without flickering

The first completion flag meant “this article has completed once during this visit.” After it became true, later attempts to trigger the animation were ignored.

That protected against repeated scroll events at the bottom, but it also prevented the animation from playing when I scrolled back and finished again.

I changed the flag into something that could rearm:

const progress = getReadingProgress(article);
 
if (progress < 0.95) {
  completed = false;
  return;
}
 
if (completed || progress < 1) return;

The two thresholds create a small gap between “ready again” and “complete.” If I used 100% for both, tiny changes around the end could repeatedly trigger feedback. Requiring a return below 95% gives the interaction a more deliberate reset.

I do not persist this reading flag in storage. It belongs to the current article interaction. A new article starts fresh.

A review caught a race between copying and completion

The PR review identified a case that normal one-action-at-a-time testing had missed.

What if someone copied the article link and reached the end while the copy confirmation was still visible?

The earlier code did this:

// Earlier version: completion can be consumed without being shown.
completed = true;
if (!copying) show("completed");

That order was wrong. If copying was true, the tick was suppressed, but completion had already been marked. A later update would think there was nothing left to show.

I moved the copy check before the completion flag:

if (completed || progress < 1) return;
if (copying) return;
 
completed = true;
show("completed");

That change alone would still depend on another scroll or resize event arriving after the copy disappeared. I also schedule a fresh progress check when the copy has finished merging. If the reader is still at the end, the completion acknowledgment can then appear.

If the reader has moved away, the current progress calculation wins. I do not replay a stale completion simply because it was relevant a few seconds earlier.

Interactive / race

Two confirmations, one place

Copy feedback takes priority, and completion follows if the reader is still at the end.
Ready to try38%

In this demonstration, the button simulates a successful copy and then moves the local progress value to 100%. Watch the status move from copied to completed. Try moving the slider back while copying to cancel the pending completion.

I added regression coverage for that sequence, pushed the fix, replied with the implementation and test result, and resolved the review thread. The useful part of the review was the behavioral question it raised, not just the line of code it pointed at.

Expanding the existing navbar for richer feedback

Once the split felt good, I wanted the navbar to respond to other meaningful actions too.

A small tick works for a brief acknowledgment. Contact submission needs more context: verification, sending, success, or failure. A copied code block also benefits from a short explanation of what happened.

I returned to Jhaemin's larger state. The reference uses a 72px-high shape with a different spring:

export const expandedIslandSpring = {
  type: "spring" as const,
  stiffness: 250,
  damping: 26,
  mass: 1.5,
};

I kept the 72px height and adapted the width to my existing navbar. The feedback target is at most 560px wide, bounded by the available viewport. At the top of a desktop page, my normal navigation can be wider than that, so the feedback state becomes taller and more focused rather than always growing horizontally.

The content follows the shape with the reference's short delayed reveal:

<motion.div
  initial={{ opacity: 0, scale: 0.9, filter: "blur(5px)" }}
  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
  exit={{ opacity: 0, scale: 0.9, filter: "blur(5px)" }}
  transition={{ duration: 0.2, delay: 0.1 }}
>
  {/* status icon, title, description, dismiss button */}
</motion.div>

Reduced motion removes the scale and blur movement. The implementation also keeps a close control available.

Interactive / expanded

The same surface, more room

These message simulations use the real feedback component, but they do not send anything.
Ready to try

These buttons simulate an outcome locally. On the real contact page, the success state only appears after the submission action returns success.

The message has to be as accurate as the motion

I did not want a satisfying animation to announce something that had not happened.

The feedback distinguishes the actual action states:

Action or resultFeedback
Contact verification beginsChecking your message
Delivery beginsSending your message
Submission succeedsMessage sent
Submission failsMessage not sent
Direct-email link is pressedContinue in your email app
Code is successfully copiedCode copied
RSS URL is successfully copiedFeed link copied

Opening a mailto: link is an invitation to continue elsewhere. I cannot confirm that an email application opened successfully, much less that a message was sent. The copy reflects that limit.

Pending contact feedback stays until the result arrives or the user dismisses it. Success and informational feedback return to navigation after three seconds; errors remain for five. The form's inline error remains available as well.

I also avoid announcing “Message not sent” just because a security widget reports a problem before the visitor has attempted a submission. The notification is tied to the attempted action.

While expanded feedback is present, the normal navigation content is hidden and inert. That prevents links underneath the confirmation from remaining accidentally clickable or keyboard-focusable. The audio state is preserved, so dismissing feedback restores the appropriate navbar content.

Keeping feedback tied to the right page

The contact form, RSS menu, and code-copy button are not children of the navbar. I needed a small way for those actions to publish feedback without coupling their markup to navigation.

I used a typed event and a hook that captures the action's pathname:

type NavbarFeedback = {
  title: string;
  description: string;
  tone: "pending" | "success" | "error" | "info";
};
 
window.dispatchEvent(new CustomEvent("navbar-feedback", {
  detail: { ...feedback, pathname },
}));

The receiver ignores feedback for another pathname, replaces the current notification, and clears the previous dismissal timer. Route changes clear the current feedback. An action that finishes after I navigate away should not announce itself over an unrelated page.

This is the scope I needed for a small website. It is not a general notification queue. Concurrent actions replace the expanded message, while the article's copy/completion interaction has its own explicit priority handling.

That boundary helps keep the implementation understandable. I do not need every button on the website to create a navbar event. Navigation links can continue to navigate. I reserve the feedback for actions where a result needs acknowledgment.

A layout regression after the feedback was added

Another screenshot showed the expanded feedback in a broken layout. “Code copied” and its description were wrapping vertically, and the close control had fallen outside the intended pill.

Fresh loads rendered correctly during inspection. The feedback's critical styles were still coupled to the article island's stylesheet, which made that dependency an unnecessary weak point while components and styles were changing in the dev server.

I could not prove every detail of the transient failure from a single screenshot. I could improve the ownership of the styles and verify the layout more directly.

I moved the expanded-feedback rules into a CSS module imported by the feedback component itself:

import styles from "./NavbarFeedback.module.css";
 
<motion.div className={styles.feedback}>
  <span className={styles.icon}>{/* icon */}</span>
  <div className={styles.copy}>{/* message */}</div>
  <button className={styles.dismiss}>{/* close */}</button>
</motion.div>

The important layout rules are straightforward:

.feedback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 12px 8px 20px;
  border-radius: inherit;
}
 
.copy {
  flex: 1;
  min-width: 0;
}
 
.dismiss {
  width: 44px;
  height: 44px;
  flex: 0 0 44px;
}

The copy can take the remaining space without forcing the other elements out. The icon and close control keep their intended dimensions. The component now owns the styles it needs to render correctly.

I added a browser assertion that checks every direct child's bounds against the expanded pill. Checking that a success message exists would not catch a close button sitting below it.

Responsiveness is also about where the circle can go

On desktop, a small satellite can sit outside a centered navbar with plenty of room around it. On a narrow phone, that same position can leave the viewport.

The implementation reserves horizontal space while a split is active on smaller viewports. The navbar temporarily becomes narrower and shifts left so the combined navbar-and-satellite group remains within the screen.

The relevant adjustment is deliberately bounded:

@media (max-width: 1100px) {
  .site-nav-shell:has([data-reading-complete]) {
    width: calc(100% - 48px);
    transform: translateX(-24px);
  }
}

That attribute represents an active split confirmation in this implementation, including copying. When the confirmation returns, the shell returns too.

I checked the original mobile drawer at 320px, as well as the mobile, tablet, and desktop test presets. I also checked the feedback text inside the larger pill. Those are related but separate layout problems: a circle can remain on-screen while the text inside its neighboring navbar still overflows.

What smooth meant, and what I could actually measure

I asked for smooth motion across mobile and desktop, including high-refresh-rate devices. I also had to distinguish the goal from the evidence available in a local browser test.

The frame budgets are easy to calculate:

Refresh rateApproximate time per frame
60Hz1000 / 60 = 16.67ms
120Hz1000 / 120 = 8.33ms

I measured a short local Chromium sample during the expanded animation:

Viewport widthMedian frame interval95th percentileSampled frames over 34ms
390px16.4ms18.2ms0
1280px16.4ms19.6ms0

That was encouraging evidence for the tested setup. It was a short viewport-based measurement on one machine. It did not certify performance on every physical phone, under thermal throttling, or on a 120Hz display.

I also avoid pretending the entire effect is free. A small translated icon is inexpensive compared with repeatedly resizing a blurred surface. This implementation uses SVG filtering, a backdrop, and width/height changes during expanded feedback. Those choices need a limited scope and actual checking.

The practical decisions were to keep the filtered area small, share position values, avoid React state updates solely to move the icon every frame, remove temporary elements after use, and avoid continuous decorative animation while nothing is happening.

The demos in this article are event-driven too. They do not run an endless animation loop while I am reading another section.

Reduced motion is part of the interaction

I want the confirmation to remain understandable when movement is reduced. The state still changes; the large positional transitions, scale changes, and blur reveals can be removed.

Each demo offers a local reduced-movement control. If the device already requests reduced motion, the demo respects that preference and does not provide a way to override it with more movement. The real navbar uses Motion's reduced-motion hook and a CSS fallback for its shell transitions.

The browser preference is exposed through prefers-reduced-motion. MDN describes the preference and its CSS behavior.

I kept the semantic feedback separate from the visual effect. Decorative SVGs are hidden from assistive technology. Meaningful status text is announced through a live region. Expanded feedback can be dismissed using its button or Escape. I do not move the reader's focus to a completion tick.

The tests that made the fixes harder to lose

I repeatedly found problems by watching the dev server. Once a problem was clear, I wanted a test that expressed the behavior I expected to keep.

By the end of the original build, the combined suite had 30 passing checks across mobile, tablet, and desktop. The later stylesheet correction also passed its focused layout checks on all three presets.

The useful coverage included:

  • Completion appears and the progress outline hides at the end.
  • Scrolling upward restores the outline.
  • The split SVG is removed after returning.
  • Another completion can play after the reading state rearms.
  • A copy confirmation does not permanently consume completion.
  • Failed clipboard writes do not announce success.
  • Contact success and failure produce different messages.
  • The original narrow mobile drawer remains usable.
  • The expanded feedback's children remain inside the pill.
  • The icon stays centered in the satellite throughout entry and merge.

For the contact tests, Turnstile and delivery responses are mocked. I can test verification, sending, success, and failure without sending a real message or bypassing the production verification service.

The alignment test samples the bounds of the SVG satellite and icon across animation frames. Its core assertion is more meaningful than checking one final screenshot:

const satelliteCenter = satellite.x + satellite.width / 2;
const iconCenter = icon.x + icon.width / 2;
 
differences.push(Math.abs(satelliteCenter - iconCenter));
 
// After sampling entry and return:
expect(Math.max(...differences)).toBeLessThan(1);
await expect(page.locator(".reading-split")).toHaveCount(0);

I used screenshots to inspect the visual result and assertions to protect specific behavior. Neither replaced the other. A clean typecheck could not tell me that the contents panel felt intrusive. A screenshot of the settled tick could not tell me that it had arrived too early.

A map of the implementation

I kept the responsibilities small enough to revisit without opening one enormous animation file.

FileResponsibility
IslandSplit.tsxThe reference SVG geometry, blur/alpha filter, and glass-edge clip
NavbarArticleIsland.tsxArticle completion, copy priority, replay, timers, and the shared position value
NavbarReadingProgress.tsxThe original gradient outline and its visibility at completion
reading-progress.tsThe shared article-range calculation
NavbarFeedback.tsxExpanded status content and its reveal
NavbarFeedback.module.cssThe expanded content's layout and responsive sizing
useNavbarFeedback.tsRoute-scoped publishing, dismissal, and lifetime of action feedback
Navbar.tsxIntegration with existing navigation, audio, and drawer behavior

Separating the SVG geometry in IslandSplit from the lifecycle timers and coordinate math in NavbarArticleIsland kept each part of the interaction easy to reason about and test independently.

The snippets throughout this post are focused excerpts highlighting the core mechanics. The production implementation wraps these primitives with passive scroll listeners, observer cleanups, reduced-motion fallbacks, and the surrounding layout. The interactive demos in this article reuse those same visual components, but run with their own local controls and explanatory state so they do not interfere with the live page.

What I would carry into the next interface

I started by asking for a split animation that felt like the reference. I ended up learning more from the things around that animation: its relationship to an icon, its collision with a glass surface, its priority over another confirmation, and its return to an ordinary navbar.

The reference code gave me a strong starting point. The website supplied the constraints. Repeated browser testing exposed the gaps between the two.

Removing the floating contents panel gave the article room again, and checking the return animation made the whole interaction feel more consistent. Turning the copy/completion race into a test also meant I could keep working without relying on my memory of every earlier problem.

The feature I wanted was a brief, useful acknowledgment inside a familiar part of my website. Every refinement had to earn its place against that goal.

When I reach the end now, I get a brief acknowledgment and the navbar returns to its normal state. That is the experience I wanted to build.

A note on the stack: This piece was drafted, styled, and published through my own bespoke CMS. Building my own publishing engine has let me shape longform interactive posts exactly the way I want them to feel.