uniswap/interface
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·August 1, 2026
Elevated
Design risk in this codebase.
More findings
Verdict
Motion is the house style here, animated stats, hover reveals, pulsing indicators, but none of it respects reduced-motion preferences. The infinite live-indicator pulse with no guard is the single biggest liability in an otherwise ambitious, animation-heavy interface.
Files Rams reviewed
apps/web/src/pages/Landing/components/TokenCloud/index.tsx
apps/extension/src/app/components/AutoLockProvider.tsx
apps/extension/src/app/components/Input.tsx
apps/extension/src/app/components/PasswordInput.tsx
apps/extension/src/app/components/Trace/TraceUserProperties.tsx
apps/extension/src/app/components/buttons/CopyButton.tsx
apps/extension/src/app/components/buttons/OptionCard.tsx
apps/extension/src/app/components/layout/ScreenHeader.tsx
apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx
apps/extension/src/app/components/modal/InfoModal.tsx
apps/extension/src/app/components/modals/SmartWalletNudgeModals.tsx
apps/extension/src/app/components/tabs/ActivityTab.tsx
apps/extension/src/app/components/tabs/NftsTab.tsx
apps/extension/src/app/components/tabs/PoolsTab.tsx
apps/extension/src/app/features/settings/components/SettingsItem.tsx
apps/web/src/pages/Landing/components/Icons.tsx
apps/web/src/pages/Landing/components/StatCard.tsx
apps/web/src/pages/Landing/components/TokenCloud/Ticker.tsx
apps/web/src/pages/Landing/components/animations.tsx
apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx
apps/web/src/pages/Landing/components/cards/LiquidityCard.tsx
apps/web/src/pages/Landing/components/cards/PillButton.tsx
apps/web/src/pages/Landing/components/cards/TradingApiCard.tsx
apps/web/src/pages/Landing/components/cards/UnichainCard.tsx
apps/web/src/pages/Landing/components/cards/UniswapXCard.tsx
apps/web/src/pages/Landing/components/cards/ValuePropCard.tsx
apps/web/src/pages/Landing/components/cards/WebappCard.tsx
apps/web/src/pages/Liquidity/CreateAuction/components/AdvancedSettingsSeparator.tsx
apps/web/src/pages/Liquidity/CreateAuction/components/AuctionAdvancedSettings.tsx
apps/web/src/pages/Liquidity/CreateAuction/components/AuctionDistributionSection.tsx
Motion
apps/web/src/pages/Landing/components/StatCard.tsx:99
Live indicator pulses forever with no reduced-motion guard
The `LiveIcon` styled div runs `pulsate` with `animation-iteration-count: infinite` and `animation-duration: 1000ms`, and nothing in this file wraps it in a `prefers-reduced-motion` media query. This is a small dot but it never stops.
Why it matters
Users with vestibular disorders who have prefers-reduced-motion enabled still get a permanent pulsing animation, which is exactly the class of motion that setting exists to suppress.
Fix
Wrap infinite animations in a `prefers-reduced-motion: no-preference` media query and freeze the frame otherwise.
export const LiveIcon = deprecatedStyled.div<{ display: string }>`
display: ${({ display }) => display};
width: 6px;
height: 6px;
border-radius: 50%;
background: ${({ theme }) => theme.success};
animation-name: ${({ theme }) => pulsate(theme.success)};
animation-fill-mode: forwards;
animation-direction: alternate;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
`export const LiveIcon = deprecatedStyled.div<{ display: string }>`
display: ${({ display }) => display};
width: 6px;
height: 6px;
border-radius: 50%;
background: ${({ theme }) => theme.success};
@media (prefers-reduced-motion: no-preference) {
animation-name: ${({ theme }) => pulsate(theme.success)};
animation-fill-mode: forwards;
animation-direction: alternate;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
`apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx:78
Rive wallet animation plays on hover with no motion preference check
`DownloadWalletCard` triggers `darkAnimation?.play()` and `lightAnimation?.play()` from `onMouseEnter` on `DarkAnimation`/`LightAnimation`, with no `prefers-reduced-motion` check gating whether `.play()` is called.
Why it matters
Users who've set reduced motion still get a full Rive animation firing every time they hover this card, which is exactly the kind of triggered motion that preference is supposed to suppress.
Fix
Check window.matchMedia('(prefers-reduced-motion: reduce)') before calling .play() on hover.
{isDarkMode ? (
<DarkAnimation onMouseEnter={() => darkAnimation?.play()} />
) : (
<LightAnimation onMouseEnter={() => lightAnimation?.play()} />
)}const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
{isDarkMode ? (
<DarkAnimation onMouseEnter={() => !prefersReducedMotion && darkAnimation?.play()} />
) : (
<LightAnimation onMouseEnter={() => !prefersReducedMotion && lightAnimation?.play()} />
)}apps/web/src/pages/Landing/components/animations.tsx:19
RiseIn entrance animation runs unconditionally regardless of motion preference
`RiseInStyles` applies `riseInAnimation` (opacity 0 to 1, translateY 100px to 0) to both `RiseInText` and `RiseIn` with `animation-fill-mode: forwards`, and nothing in this file conditions it on `prefers-reduced-motion`.
Why it matters
This entrance animation runs on every mount for every user regardless of their OS motion setting, which is a direct miss for anyone who has explicitly asked their system to reduce motion.
Fix
Gate the translateY entrance animation behind prefers-reduced-motion, falling back to an instant opacity change.
const RiseInStyles = css<{ count?: number; delay?: number }>`
opacity: 0;
animation-name: ${riseInAnimation};
animation-fill-mode: forwards;
animation-duration: 1000ms;
animation-iteration-count: 1;
animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
animation-delay: ${(props) => 1000 * (props.delay ?? 0)}ms;
`const RiseInStyles = css<{ count?: number; delay?: number }>`
opacity: 0;
animation-fill-mode: forwards;
animation-delay: ${(props) => 1000 * (props.delay ?? 0)}ms;
@media (prefers-reduced-motion: no-preference) {
animation-name: ${riseInAnimation};
animation-duration: 1000ms;
animation-iteration-count: 1;
animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
}
@media (prefers-reduced-motion: reduce) {
opacity: 1;
}
`apps/web/src/pages/Landing/components/TokenCloud/Ticker.tsx:28
Ticker hover reveal transitions every property, not just the ones changing
The `Flex` wrapper in `Ticker` uses `transition="all 0.1s ease-in-out"` to animate the `$group-item-hover` state, but only `opacity` and `x` actually change on hover.
Why it matters
Transitioning `all` forces the browser to watch every animatable property including layout-affecting ones, which causes unnecessary paint work and can produce visible jank on this hover-revealed token ticker, especially with many tickers mounted at once.
Fix
Scope the transition to the specific properties that change (opacity, transform) instead of transitioning all.
transition="all 0.1s ease-in-out"transition="opacity 0.1s ease-in-out, transform 0.1s ease-in-out"Accessibility
apps/web/src/pages/Landing/components/StatCard.tsx:186
Stat value split into rotating character sprites has no accessible text
`AnimatedStringInterpolation` splits `value` into individual characters and renders each as a `NumberSprite` inside a `Mask`, with no `aria-label` on the wrapping element carrying the full number. A screen reader has nothing coherent to read for this stat.
Why it matters
Screen reader users lose the actual headline number on the page (the thing this component exists to show), turning a key marketing stat into either silence or fragmented character noise.
Fix
Add an `aria-label` with the full formatted value on the sprite container and hide the decorative per-character markup from assistive tech.
return (
<Mask>
{chars.map((char: string, index: number) => {return (
<Mask aria-label={`${props.prefix ?? ''}${value}${props.suffix ?? ''}`} role="text">
{chars.map((char: string, index: number) => {
// decorative per-character sprites, hidden from AT via aria-hidden belowGet this score on every PR.
Rams reviews each pull request on your repo and posts inline one-click fixes — about a minute per review.
Install Rams freeComponents
apps/web/src/pages/Landing/components/Icons.tsx:6
Icon fill prop is optional with no default, risking invisible icons
`IconProps` declares `fill?: string` with no default value, and every icon (`Wallet`, `Computer`, etc.) passes `fill={props.fill}` straight through. Any consumer that forgets to pass `fill` renders `fill="undefined"` on the path.
Why it matters
An icon silently rendering with an invalid fill value produces an inconsistent or missing icon in production with no type error to catch it, and the bug only surfaces visually.
Fix
Give fill a sensible default (e.g. currentColor) so omitting the prop degrades gracefully instead of breaking.
export function Wallet(props: IconProps) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="..."
fill={props.fill}
/>export function Wallet({ fill = 'currentColor', ...props }: IconProps) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="..."
fill={fill}
/>Typography
Color
Spacing
UX
Craft
Working well
- The riseInAnimation uses a deliberate custom cubic-bezier ease-out curve for entrances rather than a default ease, giving the reveal an intentional decelerating feel.
- Container correctly falls back to theme.surface2 for the non-live background, keeping the base surface themeable even though the live-state color is hardcoded.
- Memoizing absChangeFormatted with useMemo keyed on formatPercent and pricePercentChange avoids redundant formatting work on re-render.
- Navigation errors from the async navigate() promise are caught rather than left unhandled, avoiding an unhandled promise rejection.
Scored August 1, 2026 with Rams Engine v0.0.3 · Engine changelog
First scored July 26, 2026: 59/100. This rescore on v0.0.3: 59/100.
This page is an automated design review of uniswap/interface’s UI code: 30 files read against 291 versioned rules covering accessibility, color, typography, spacing, components, UX, motion, and craft. The score is out of 100; confirmed criticals cap it — one at 59, two at 49, three or more at 39.
More design scores
Score your own repo.
Free on public repos, no account. The same engine that scored this page reads your UI code and mints a score page like this one.
Public repos only. The full engine reviews the UI code and mints a public score page — we email you the link too. Already-scored repos open instantly.