Why a UI feels cheap and how to fix it: optical alignment, press down feedback, empty/loading/error states, typography, spacing scales, and haptics in Flutter.
A client once told me our app "felt cheap," and when I asked what looked wrong, he couldn't name a single thing. He just knew. We shipped roughly the same palette as a competitor he loved, a similar layout, arguably nicer illustrations. His gut still said one was worth paying for and ours wasn't. That kind of unfalsifiable complaint used to drive me up the wall. Now I think it's some of the most honest feedback a user can give you.
"It feels cheap" is real data. It's just aggregated. The brain runs a thousand tiny checks per screen and reports back a single scalar: trust, or not. Nobody consciously registers that an icon sits two pixels too far left, or that a button doesn't react until 300ms after they tap it. They feel the sum. Premium isn't one grand visual gesture. It's the absence of a hundred small wrongnesses, and after six years of shipping Flutter apps out of Dubai, I've learned that closing that gap is mostly boring, unglamorous work that nobody puts on a portfolio.
This post is the checklist I actually run — the perceived-quality audit I do before a release — with the reasoning behind each item and the Flutter code I reach for. Steal all of it.
When someone says a UI feels off, they've detected a mismatch between what they expected and what happened — and their conscious mind never got the memo about why. Your job is to reverse-engineer the mismatch.
The trap is treating that feedback as taste. It isn't. Taste is "I'd have used blue." Cheapness is structural: inconsistent spacing, sloppy alignment, feedback that arrives late or not at all, states nobody bothered to design. Those are measurable and fixable. The reason they go unfixed is that each one, in isolation, looks too small to justify a ticket. Nobody files a bug titled "the empty state is a sad gray box." So the debt compounds silently until the whole thing reads as amateur hour.
I've started auditing for cheapness the same way I audit for performance: assume it's there, go looking with a checklist, measure before and after. There's a name for the thing you're chasing — perceived quality, sometimes called "product feel" or UI craft — and it's the single biggest gap between apps that look identical in a screenshot and feel worlds apart in the hand. The rest of this post is that checklist.
Here's the one that changed how I look at every screen. Your layout engine aligns by bounding box. Your eye aligns by visual mass. These are not the same thing, and the gap between them is exactly where "off-center" lives.
The classic case is a play button inside a circle. Center the triangle mathematically and it looks shifted left, because a triangle's visual weight sits toward its base. You have to nudge it right by a couple of pixels until it looks centered. Mathematically it's now "wrong." Optically it's finally right. When math and the eye disagree, the eye wins. Always. Your users don't have the bounding boxes; they only have their eyes.
The same problem shows up all over an interface:
In Flutter I keep a tiny helper for exactly these fudge factors, so the nudges live in one documented place instead of as unexplained magic numbers scattered across forty widgets:
/// Optical corrections. These are intentional, not bugs./// If a number here looks arbitrary, that's the point — trust the eye.class Optical { /// Play/media glyphs read left-heavy; shift right. static const playNudge = EdgeInsets.only(left: 2); /// Icons ride high against a text baseline; drop them a touch. static const iconBaselineNudge = EdgeInsets.only(top: 1); /// Circles look smaller than squares of equal size. static double circleToSquare(double squareSize) => squareSize * 1.08;}A concrete way to catch these: screenshot the widget, drop a guideline down the mathematical center in any image editor, and look. If your eye argues with the line, your eye is right. That five-second check has saved me from shipping "centered" play buttons more times than I'll admit. Mathematical centering is a starting guess, not the answer. The person who trusts their eye and adjusts ends up shipping the screen that feels considered.
Cheap interfaces make you wonder whether the tap even registered. Expensive ones answer before you've finished asking the question. The rule I hold my team to: every intentful touch gets acknowledged within roughly one frame — about 16ms at 60fps — even if the real work takes a full second.
The acknowledgement doesn't have to be the result. It just has to be a sign of life. The button dips, the ripple starts, the row highlights — something moves the instant a finger lands. Perceived speed is governed far more by that first-frame reaction than by how long the network call actually takes. This is the cheapest trick in the whole book and the one teams skip most.
A few things I've learned to get right here:
// A press that feels physical: react on down, spring on release.AnimatedScale( scale: _pressed ? 0.96 : 1.0, duration: const Duration(milliseconds: 120), curve: Curves.easeOutBack, // slight overshoot on the way back child: GestureDetector( onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), child: buttonBody, ),);
For anything more elaborate than a scale, drive it with an AnimationController rather than an implicit widget, because a controller is interruptible by design — you can call reverse() from wherever the value currently sits, and Flutter's spring simulations (SpringDescription feeding a SpringSimulation) will carry the current velocity into the reversal. That's the difference between an animation that feels like a physical object changing its mind and one that feels like a video scrubbing backward.
On a recent revamp we didn't make a single API call faster. We added press states and swapped linear curves for springs on the primary flows, maybe a day of work total. The app "felt faster" in every internal test afterward. It wasn't. The p95 was identical. It had just stopped feeling dead. I've stopped being surprised by how often perceived latency and actual latency point in different directions.
Most products design the happy path and treat everything else as an afterthought. That afterthought is exactly where cheapness leaks in, because empty and error states are what a brand-new user sees first and a frustrated user sees most. The two people whose opinion decides whether you keep them.
Three states, three sins to avoid:
A pattern I now bake into every list view is an explicit state type, so no state can go unhandled by default:
switch (state) { Loading() => const ItemSkeletonList(), // matches real rows Empty() => EmptyState( title: 'No saved tools yet', body: 'Star a tool and it lands here for one-tap access.', action: BrowseToolsButton(), ), Error(:final message) => ErrorState( message: message, onRetry: _reload, // always a way forward ), Data(:final items) => ItemList(items),}The discipline is refusing to let any state fall through to a default of nothing. When the compiler forces you to handle all branches — Dart's exhaustive switches over a sealed class do exactly this, and will refuse to compile the moment you add a new variant without handling it — cheapness loses one of its favorite hiding spots. I like designs where the type system does the nagging so I don't have to.
The skeleton screen deserves its own note, because it's the highest-leverage of the three. A skeleton that mirrors the real row geometry — same avatar circle, same two lines of text at the same widths — sets the user's expectation for the layout before the data arrives, so the content doesn't visually jump when it lands. A generic spinner tells the user nothing except "wait." A shaped skeleton tells them "here's what's coming," and that quiet promise is a big part of why polished apps feel fast even on a bad connection.
Nothing screams cheap louder than crammed text. Typography is where amateurs quietly lose the plot, because the framework defaults are almost never right and almost always survive to production untouched.
The levers that matter most:
FontWeight.bold on everything you want to emphasize flattens the whole page. A considered UI uses two or three weights deliberately — regular for body, medium for labels, semibold for headings — so emphasis actually means something.final text = TextTheme( displayLarge: TextStyle(fontSize: 34, height: 1.1, letterSpacing: -0.5, fontWeight: FontWeight.w600), bodyLarge: TextStyle(fontSize: 16, height: 1.5, letterSpacing: 0.0, fontWeight: FontWeight.w400), labelSmall: TextStyle(fontSize: 12, height: 1.3, letterSpacing: 0.6, fontWeight: FontWeight.w500),);
And a quieter one: align text to a vertical rhythm. When headings, body, and spacing all snap to a consistent baseline unit, the page feels composed rather than assembled from parts. You rarely notice a good rhythm when it's there. You always feel its absence when it isn't. One practical tip in Flutter — watch TextField and Text metrics closely, because the default TextHeightBehavior can add or trim leading in ways that quietly break your rhythm; nail down height explicitly rather than trusting the defaults to stay put across platforms.
This is the least creative section in the post and possibly the highest-leverage. Inconsistency in the small stuff is the fastest way to look like three different people built your app across three different weeks — which, on most teams, is precisely what happened. This is the whole argument for a design system: not to be fancy, but to make "the same" actually be the same everywhere.
class Space { static const xs = 4.0, sm = 8.0, md = 16.0, lg = 24.0, xl = 32.0;}// Usage everywhere: Space.md, never a raw 15 or 17.I enforce this with design tokens — spacing, radii, colors, and elevation all pulled from named constants rather than literals — and, where the tooling allows, a lint rule that flags raw padding values. The moment magic numbers start creeping back into paddings, the drift has already begun; the lint just tells you the day it started instead of the quarter. If you centralize these tokens in your ThemeData and a small set of constants, restyling the entire app later becomes a change in one file instead of a search-and-replace across the whole codebase — which is the difference between a design system that scales and one that rots.
This is the part almost nobody does, which is exactly why doing it is what makes an app feel expensive. A competitor can copy your layout from a screenshot in an afternoon. They can't copy this from a marketing image, because none of it survives a screenshot.
HapticFeedback.lightImpact(), selectionClick(), and heavyImpact() map to different textures; match the weight of the feedback to the weight of the action.// Match the haptic to the moment; never fire it on every tap.void onPurchaseConfirmed() { HapticFeedback.mediumImpact(); // a satisfying, deliberate thunk showToast('Payment sent — receipt on its way');}There's a quiet accessibility win hiding in this section too: haptics and clear microcopy help people who can't or don't rely on the visual channel alone, and a disabled button that explains why it's disabled is both more polished and more usable than one that just sits there greyed out. Craft and accessibility point the same direction more often than people assume.
None of these show up in a screenshot. All of them show up in how the thing feels in your hand at 11pm. That's the whole game: the parts a competitor simply cannot lift from a marketing image, because they only exist in motion.
Run this over any screen you own. Every "no" is cheapness with a home address.
Score it honestly. On most screens I audit, the first pass turns up six or seven "no"s, and fixing them is a day of work that moves the needle further than a month of new features.
switch over a sealed class force you to handle every one.