The Difference Between Code That Works and Code That Feels Good Is in These Details
I built a screen that puts a floating tab bar above a 3D canvas. The first working version took half a day; getting it to feel right took much longer. Notes on the problems I hit and what they taught me.
I rebuilt a screen that keeps a 3D view on a WebGL canvas and floats a bottom tab bar above it.
Tap a tab and a panel rises over the 3D scene, or the user navigates to a page.
The first “it works for now” version came together with a few clicks. It only worked. Every tap felt slightly off, and the whole thing felt cheap. Getting from there to a version that felt right took much longer.
Building is quick every time; polishing takes twice as long. It is difficult to automate, too: even if you pile on harnesses, the result still does not feel satisfying.
In the end, revising and shaving it down one detail at a time is much better.
Use transform for things that move
At first, I used a spring to increase the panel’s height as it rose—changing height every frame. I expected it to look smooth, but frames dropped on every tap.
The reason was simple. Changing height on every frame makes the browser recalculate layout on every frame. The panel also had blur applied, with live 3D running behind it. Per-frame layout, blur recalculation, and 3D compositing all collided at once: the worst combination.
The browser rendering pipeline runs Layout → Paint → Composite. Properties such as width, height, top, and left restart the work from Layout, while transform and opacity affect only the final Composite stage. The GPU only has to move a layer it already drew.
So I dropped the height-morph and changed to sliding a fixed-height sheet with translateY. I also removed blur from the moving panel and switched to a solid background, because recalculating blur above live 3D every frame was the most expensive part.
During an interaction, it is safest to keep moving properties to
transformandopacity. Animating layout properties spreads the cost through the entire child tree.
The selection indicator—the box that follows the pressed tab—kept appearing in the wrong place. At first I grabbed each button, read offsetLeft and offsetWidth, and moved the indicator to that position.
There were two layers to the problem. One was timing: a measurement is accurate only after layout has settled, but mount timing and font loading changed widths and kept shifting the moment of measurement. Sometimes the first measurement was stuck at zero. The other was the coordinate system. offsetLeft is relative to the parent’s border box, while an absolutely positioned indicator’s left:0 is relative to the padding box, so it was always off by the parent padding. The extra offset added to correct it broke another case.
Eventually I realized that the pattern of reading from layout and writing it back is itself fragile. Layout must be settled at exactly the instant you read it, and that guarantee is harder to get than it seems.
So I abandoned measurement altogether. I made the buttons fixed-size squares and positioned the indicator through a pure calculation using only activeIndex. Multiply the index by the step size and you have the position. Reading zero lines from the DOM removed both timing problems and coordinate mismatches.
Do not measure a value you can calculate. Code that responds by reading layout is a genuine last resort.
Using ease-in and ease-out well
I kept wondering, “It looks fine when the panel comes up, so why does it snap shut?” The cause was using one deceleration curve—ease-out—for a bidirectional transition. It happens often in Svelte.
When opening, a deceleration curve settles softly and feels good. But if you reverse the same curve simply by reversing progress when closing, the value remains almost fully open and then drops at the very end. In time, it becomes “nearly open, then suddenly closed at the finish.”
Entrance and exit want different motion from the outset. An element should decelerate as it arrives and accelerate as it leaves. So I used ease-out for entering motion and ease-in for exiting motion, with both set to 240ms.
Easing is not decoration; it conveys the meaning of motion. Entrance ≠ exit.
Springs are not magic
When I moved the indicator with a spring, the feedback was: “The yellow box follows too late.” Springs are physics-based, so they chase a target gradually. That can be smooth, but for UI such as a tab highlight—something that should attach almost the instant it is pressed—the delay feels sluggish. The slight overshoot was distracting too, as was the unwanted initial motion that slid from zero to the active position on mount.
I removed the spring and used an ordinary CSS transition instead: a quick 200ms ease-out. It attaches to the tab immediately; when tapped repeatedly, the in-progress transition naturally changes direction from its current position. CSS transitions also do not apply to the initial value of the first render, so the mounting slide disappeared.
For a state-toggle highlight, a deterministic, interruptible CSS transition is a better fit than a physics spring. More expensive and complex is not always better.
Using z-index well
An HTML overlay floating above the 3D view—like a speech bubble over a character’s head—popped above the tab bar and panel. Raising the tab bar’s z-index did not beat it.
Following the source, I found that the library had deliberately assigned the overlay a z-index in the millions so it would sit above the canvas. The side effect was that it covered every other piece of UI.
The real key here was stacking context. If that overlay genuinely has a z-index of ten million at the page root, the tab bar can never win. But if the overlay is trapped inside a low-z context, ten million means something only inside that context; at the page level it is just the z-index of that context. Elements created with position + z-index create a new stacking context, and child z-index values cannot leak outside it.
So I made two decisions. First, I directly constrained the library overlay’s z range: above the canvas, below the floating UI. Second, I intentionally designed and documented the full layer order: canvas < overlay < backdrop < tab bar and popups < splash < toast.
When 3D or WebGL mixes with the DOM, z-index is not a competition for the largest number; it is a stacking-context design problem. If a library uses z-index values in the tens of millions, do not make yours bigger—contain it.
Eighty percent of “this feels weird” is missing detail
I thought, “The selected-state tab-button design feels strange—especially the animation.”
The biggest culprit was layout shift. An inactive tab contained only an icon, while an active tab used icon plus label, so every tap pushed the icon from the center to the left and rearranged it. On top of that, the label fade and sliding indicator moved independently, making the result feel busy.
So I made small adjustments. I removed the label altogether so the icon would not jump on selection, and aligned the inner round shape concentrically by matching it to the outer radius minus padding. I added a slight press-scale response (around 0.96; lower than 0.95 looks exaggerated), enlarged and emboldened the active icon a little, removed bounce from the highlight, and named only the properties that move instead of using transition: all. The hit target exceeded the minimum recommendation.
Most “this feels weird” feedback is the sum of small things: layout shift, misaligned radii, and missing feedback. Each is trivial alone, but together they create a cheap feeling.
Think about the network, too
“It bothers me that this fetches everything from the API every time the screen opens.” That is not a pixel problem but a data-flow problem, and it directly affects perceived smoothness.
Because the panel unmounted when it closed and mounted again when it opened, it fetched the whole list on every open. It had local data, so the screen was not blank, but it still requested the full payload each time. The interesting part was that other data on the same screen was already handled well: a TTL cache plus invalidation on mutation. One place was simply missing that guard.
So I added a TTL guard only where it was missing. If the same request had succeeded with the same conditions within a set interval, the network request was skipped. The content changed almost daily at most, so a short TTL was enough. The key is stale-while-revalidate: show the cache immediately, then explicitly invalidate it when a mutation such as a purchase or deletion occurs.
“Fetch fresh every time it opens” is the lazy answer. The real answer is cache plus mutation invalidation. Leaving already-good code alone is engineering, too.
“It snaps shut,” “it follows too late,” “it feels weird.” When I dug into those vague discomforts, each had a concrete cause. This work is ultimately a contest to shorten that feedback loop. Arguing with Claude Code is fun, but polishing details this way every time is tiring. Building features is more fun; refinement takes some of that pleasure away.
Making a feature that works takes half a day. I spend the rest of the time on the distance between that and code that feels good.
Comments
height 매 프레임 바꿔서 프레임 떨어졌다는 거 완전 국룰 실수ㅋㅋ transform으로 갈아탄 게 신의 한 수네
offsetLeft는 border box 기준이고 절대배치 left:0은 padding box 기준이라 어긋난다는 거 오늘 처음 알았다
ease-out만 양방향으로 쓰면 닫을 때 훅 떨어지는 거 완전 공감ㅋㅋ 근데 240ms는 어떻게 정한 숫자야?