Rams MCP · The full engine, now in your coding agent
uniswap on GitHub

uniswap/interface

Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.

30 files reviewed·August 1, 2026

View on GitHub

Elevated

Design risk in this codebase.

9issues
Critical, Serious & Moderate · top 6 shown below

Top fix

Add reduced-motion guard to the infinitely pulsing live indicator

See the fix

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

91/100

Motion

1 critical3 serious
MotionCritical

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;
  }
`
MotionSerious

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()} />
)}
MotionSerious

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;
  }
`
MotionSerious

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"
98/100

Accessibility

1 serious
AccessibilitySerious

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 below

Get 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 free
98/100

Components

1 serious
ComponentsSerious

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

No issues found

Color

No issues found

Spacing

No issues found

UX

No issues found

Craft

No issues found

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.

Or get a design review on every pull requestInstall Rams