Why Your App Looks Right but Feels Wrong


Greetings, traveler!

You can put a screenshot of an app next to its Figma mockup and find almost nothing to complain about. The margins match, the typography is right, the colors are right, and every button is exactly where the designer put it. Then you start using the app and something feels a little off.

Maybe a view appears too abruptly. Maybe the custom back button works, but the transition around it doesn’t feel like the rest of iOS. A component that looked balanced in the mockup feels cramped once the title gets longer. On another device the layout still works, technically, but the proportions suddenly look strange.

This happens because matching a static design is only part of UI implementation. The user never experiences the app as a set of screenshots. They experience what happens between those screenshots, and they do it inside a platform that already has its own behavior and conventions.

A static frame cannot describe the whole interaction

A static mockup is excellent at answering spatial questions. It shows where the title goes, how content is grouped, how large a card should be, which font to use, and how much space the designer expects around an element.

It says much less about time. Take something simple, such as an expandable section. The design may contain a collapsed state and an expanded one. A developer can reproduce both perfectly and still make the component feel awkward, because the interesting part happens while it changes from one state to the other.

The container has to resize while the rest of the layout moves with it. New content has to appear at some point during that change. If the user taps again before the animation finishes, the component should react without jumping or getting stuck in an intermediate state.

None of that is visible in the final screenshots. The same issue shows up all over an app. Loading turns into content. A menu appears and disappears. A button becomes disabled. A row is inserted. A sheet is dismissed interactively. Each final state may be well designed, while the transitions between them are left to whoever happens to implement the feature.

That is often where an interface starts to lose consistency. One developer adds a subtle transition, another uses the default animation, and a third does nothing because the ticket only shows the final state. QA can compare every screen with Figma and approve all three.

Cross-platform design needs room for the platform

Shared design systems make sense. An iOS user and an Android user should still recognize the same product, understand the same information hierarchy, and find the same features.

Problems start when consistency is interpreted as a requirement to reproduce every interaction in exactly the same way. A date picker does not need to behave identically on both platforms to belong to the same product. Neither does a context menu, a search interface, a confirmation flow, or a modal screen. Users already know how those things normally behave on their device, and ignoring that knowledge creates extra work for both the team and the user.

There is also a practical difference between sharing a visual language and sharing an implementation idea. Colors, typography, icon style, content hierarchy, and branding can remain consistent while the interaction follows the conventions of each platform.

This matters because people do not approach an iOS app with a blank slate. They have used dozens of other iOS apps before opening yours. A familiar gesture or transition requires almost no thought. If your version works differently, it needs a reason and enough attention to make the new behavior coherent.

Navigation exposes the problem quickly

Navigation is one of the easiest places to see the gap between a static design and a running interface.

Suppose a designer creates two screens, each with a custom navigation bar. Both look great. The developer reproduces them with something like this:

VStack(spacing: 0) {
    CustomNavigationBar(
        title: "Details",
        leadingAction: .back,
        trailingAction: .edit
    )

    content
}

As a screenshot, this can be indistinguishable from the design. The missing part is what happens when the previous screen becomes this one.

That became more noticeable in iOS 26+. System navigation transitions are more fluid, toolbar items can participate in the transition between screens.

A custom bar does not get an equivalent transition just because it resembles the system bar in its resting state. The team has to decide how its title and buttons move, fade, or transform, and how all of that behaves while the user performs an interactive back gesture.

If nobody designed that behavior, the usual result is much simpler: the old bar disappears and the new one appears. There may be no obvious bug. The screen opens, the Back button works, and the screenshots are correct. It simply feels different from navigation elsewhere on the device.

This is why animation deserves attention before the final polish pass. In many cases it explains the relationship between states rather than decorating them after the fact.

Custom components need a behavioral spec too

Building a custom component is not a problem by itself. The problem is treating its static appearance as a complete specification.

A custom navigation bar, for example, may be described in great detail in Figma. The file can specify its height, background, icon sizes, title position, and every spacing value. That still leaves open questions about how the component behaves when navigation changes.

New hardware makes those assumptions easier to notice. On iPhone Duo, controls that normally sit along the top or bottom can move to the side in some configurations. System navigation and toolbar components know how to participate in those layouts. A custom bar that was designed as a horizontal strip at the top remains a horizontal strip until the team adds another behavior for it.

That may be completely acceptable. Perhaps the product really needs its own navigation model and the team is prepared to maintain it. The important part is understanding the scope of the decision when the component is designed.

Accessibility is easy to miss for the same reason. A custom control may look finished in the mockup while still needing work for VoiceOver, larger text sizes, Reduce Motion, or keyboard input. System controls already handle much of this because that behavior is part of the component, even though it is invisible in a static design. Once you replace the control with your own implementation, those cases become part of the work as well.

Even smaller controls have behavior that is easy to omit from a static specification. Press feedback, disabled states, content changes, cancelled gestures, and insertion or removal from the hierarchy all affect how the component feels in use. They do not all require dedicated Figma prototypes, but somebody should know whether the behavior is custom or whether the platform should handle it.

Mockup measurements have a habit of becoming layout rules

Static designs also produce a lot of numbers, and those numbers tend to find their way into code.

Consider this:

let usesCompactHeader = UIScreen.main.bounds.height < 750

There is usually a reasonable story behind code like this. Perhaps the large header looked bad on one smaller iPhone. A developer tried a breakpoint, checked the simulator, and shipped the fix. Six months later, 750 looks like a deliberate layout rule even if it never was one.

This is worth revisiting now that UIScreen.main is deprecated. The deprecation itself is not especially interesting as a migration task. You can get a screen through more appropriate APIs when you genuinely need one.

Layout code often does not need one in the first place. If a header becomes too large when space is limited, the useful information is probably the space available to that layout, not the dimensions of a global display. Replacing UIScreen.main with another way to fetch a UIScreen can preserve the original assumption without solving the reason the code became fragile.

Device orientation is not a very reliable layout rule either. An iPad can be physically in landscape while your app occupies a narrow Split View column that is taller than it is wide. In that case, knowing how the device is being held tells the view less than knowing how much space its own container has.

Size classes are still useful when the decision is genuinely broad, such as choosing between a compact and a wider presentation. They just are not measurements. A .regular horizontal size class does not tell you how many points are available, so layouts that depend on actual dimensions still need to respond to the geometry of the relevant container.

The same is true of fixed frames:

.frame(width: 390)

Sometimes 390 is intentional. There are perfectly valid reasons for a view to have a fixed size.

It is worth being more suspicious when the number exists because 390 happened to be the width of the simulator used during implementation. A value chosen to fix one screenshot can quietly become part of the layout architecture.

Not every number you can measure belongs in the code

Spacing is a less dramatic version of the same problem. Open a Figma file and you can measure everything. There might be 8 points between an icon and a label, 16 around the content, or 24 before the next section. It is tempting to copy those values directly into the implementation.

Sometimes that is exactly what you should do. If a component has a defined spacing value or the design system uses a particular grid, the code should reflect it. But a measured distance can also reflect an existing platform convention rather than a deliberate choice made for that particular screen.

SwiftUI already has cases where leaving a value unspecified carries useful information. Calling .padding() without an argument lets the framework choose its standard padding instead of turning a measured value from a mockup into another constant in the code.

Stack spacing is even more interesting. Consider this:

HStack {
    Image(systemName: "person")
    Text("Account")
}

With no explicit spacing, SwiftUI does not simply insert the same fixed gap between every pair of views. The spacing can depend on the adjacent views and their spacing preferences.

Now compare it with:

HStack(spacing: 8) {
    Image(systemName: "person")
    Text("Account")
}

Here, 8 becomes an explicit rule of the component. That may be exactly what the design calls for. The question is whether the value was chosen intentionally or copied because that happened to be the distance visible in one mockup.

Leaving spacing unspecified gives SwiftUI room to apply the spacing expected for those views. Using an explicit value tells it that this particular distance belongs to your design and should stay fixed. Both approaches are useful, but they express different intentions.

Some of the problem happens during handoff

Design, development, and QA can all do their jobs well and still miss the same thing if everyone is reviewing the same static artifact.

The designer checks the frame. The developer implements it and compares the result with the frame. QA does the same comparison on the build. Visual bugs are found, which is useful, but behavior that was never described may pass through the entire process without anyone making a deliberate decision about it.

Navigation transitions are one example. Reusing the same custom component across several teams is another. If every team fills in the missing behavior independently, small differences accumulate even when the component looks identical everywhere.

A better handoff does not need a prototype for every tap. That would create its own kind of overhead. It does help to call out interactions where the behavior matters, especially when the team is intentionally replacing something the platform already provides.

Sometimes a short recording explains an interaction faster than several additional frames. In other cases the design can simply rely on the standard system behavior, which is also a useful specification.

The main goal is to avoid leaving important behavior undefined and then discovering during QA that three parts of the app interpreted the same component differently.

Review the app while it is moving

Screenshot comparison remains useful. It catches alignment problems, incorrect typography, wrong colors, and plenty of other real bugs. I would not replace it.

I would add another kind of review. Use the feature normally for a minute. Open and close the screen a few times. Try interactive navigation instead of always tapping Back. Change the text size. Put longer content into places that were designed around a short English string. If the same custom component appears in several flows, compare how it behaves in each of them.

Many UI problems become obvious only then. They are difficult to express as a mismatch with Figma because there is no single incorrect frame to point at.

This is also why reviewing the build on a device matters. A design tool is a good place to inspect composition. It cannot reproduce every part of the environment in which the finished interface will run.

Matching Figma is only part of the job

A developer cannot ignore the design, replace everything with defaults, and call the result more native. However, the opposite extreme is just as limiting. Reproducing every visible property of a mockup does not guarantee that the finished interface will feel right once it starts responding to the user.

Some spacing values should be explicit. Others can come from the platform. Some custom controls are worth the extra work because the interaction is important to the product. In other places, replacing a system component creates work that was never visible in the original design file.

You might consider whether those choices were deliberate. Figma gives the team a very good description of how an interface should look. The implementation still has to decide how that interface behaves when it moves, changes size, and meets the rest of the platform.