shadcn-ui/ui
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
Verdict
Scaffold defaults are shipping as product patterns — hardcoded values, broken contrast, and missing semantics are baked in at the foundation. The single biggest risk is that every team copying these components inherits the accessibility failures without knowing it.
Files Rams reviewed
apps/v4/app/(app)/(root)/components/index.tsx
apps/v4/app/(app)/examples/rtl/components/index.tsx
packages/shadcn/test/fixtures/frameworks/next-app-src/src/app/page.tsx
packages/shadcn/test/fixtures/frameworks/t3-app/src/app/page.tsx
apps/v4/app/(app)/(root)/components/appearance-settings.tsx
apps/v4/app/(app)/(root)/components/button-group-demo.tsx
apps/v4/app/(app)/(root)/components/button-group-input-group.tsx
apps/v4/app/(app)/(root)/components/button-group-popover.tsx
apps/v4/app/(app)/(root)/components/empty-avatar-group.tsx
apps/v4/app/(app)/(root)/components/empty-input-group.tsx
apps/v4/app/(app)/(root)/components/field-choice-card.tsx
apps/v4/app/(app)/(root)/components/field-demo.tsx
apps/v4/app/(app)/(root)/components/field-hear.tsx
apps/v4/app/(app)/(root)/components/input-group-button.tsx
apps/v4/app/(app)/(root)/components/input-group-demo.tsx
apps/v4/app/(app)/(root)/components/input-group-textarea.tsx
apps/v4/app/(app)/(root)/components/item-avatar.tsx
apps/v4/app/(app)/(root)/components/item-demo.tsx
apps/v4/app/(app)/(root)/components/notion-prompt-form.tsx
apps/v4/app/(app)/(styles)/sera/article-directory/components/article-directory.tsx
apps/v4/app/(app)/(styles)/sera/article-directory/components/preview-header.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/demographics.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/metrics-grid.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/preview-header.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/top-editorial.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/traffic-overview-deferred.tsx
apps/v4/app/(app)/(styles)/sera/audience-analytics/components/traffic-overview.tsx
apps/v4/app/(app)/(styles)/sera/components/lazy-preview.tsx
apps/v4/app/(app)/(styles)/sera/components/theme-switcher.tsx
apps/v4/app/(app)/(styles)/sera/edit-article/components/editor-workspace.tsx
Accessibility
Card body text at opacity-50 fails WCAG AA contrast
All four card descriptions use `opacity-50` on `text-sm` body copy: - "Find in-depth information about Next.js features and API." - "Learn about Next.js in an interactive course with quizzes!" - "Explore the Next.js 13 playground." - "Instantly deploy your Next.js site to a shareable URL with Vercel." On a white background, `text-sm` at 50% opacity renders at roughly #808080 — approximately 3.95:1 against white, which fails WCAG AA (4.5:1 required for small text).
Why it matters
Small text below 4.5:1 is unreadable for users with low vision and fails WCAG 1.4.3. If this pattern is copied into product code, it will propagate a contrast failure across every card component that inherits it.
Fix
Use a fixed opacity or color token that achieves at least 4.5:1 for small text — opacity-60 on a dark foreground color clears the threshold on white.
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}><p className={`m-0 max-w-[30ch] text-sm opacity-70`}>External links open in new tab with no screen reader warning
Both `<Link>` cards use `target="_blank"` — "First Steps →" and "Documentation →" — but neither provides an `aria-label` or visible text indicating that a new tab will open.
Why it matters
Screen reader users are disoriented when focus unexpectedly jumps to a new browser context with no forewarning. WCAG 2.4.4 requires link purpose to be clear from context.
Fix
Add aria-label text that includes "opens in a new tab" for any link with target="_blank".
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
><Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
aria-label="First Steps — opens in a new tab"
>FieldSeparator inline conditional renders untranslated language strings as visible text
The FieldSeparator renders: - Hebrew: 'הגדרות מראה' - Arabic: 'إعدادات המظהר' This text is a section label but it has no associated heading role or aria-label. The surrounding grid columns have no headings at all — the demo grid has no document structure, so screen readers encounter a flat list of interactive components with no landmark or heading to navigate between sections.
Why it matters
Screen reader users can't jump between sections in this demo grid. The FieldSeparator is the only labeling element and it has no semantic role, so the section boundary it marks is invisible to assistive tech.
Fix
Give the FieldSeparator an aria-label or render its content inside a heading element so screen readers can navigate the section boundary.
<FieldSeparator className="my-4">
{language === "he" ? "הגדרות מראה" : "إعدادات المظهر"}
</FieldSeparator><FieldSeparator className="my-4" role="heading" aria-level={2}>
{language === "he" ? "הגדרות מראה" : "إعدادات المظهر"}
</FieldSeparator>"Virtual Machine" coming-soon state is color- and text-only with no disabled semantics
The "Virtual Machine" `RadioGroupItem` has no `disabled` attribute despite the label reading "(Coming soon)". It is fully selectable, meaning a user can choose an unavailable option and submit a broken configuration. The only signal that it's unavailable is the parenthetical copy in the description.
Why it matters
Without `disabled`, screen readers announce this as a valid choice, keyboard users can select it, and form submission could pass an invalid value. The parenthetical text is not a substitute for a machine-readable disabled state.
Fix
Mark unavailable options as `disabled` so the control's state is semantically correct and the option is non-selectable.
<RadioGroupItem
value="vm"
id="vm-z4k"
aria-label="Virtual Machine"
/><RadioGroupItem
value="vm"
id="vm-z4k"
aria-label="Virtual Machine (coming soon)"
disabled
/>Components
Unconventional child selector syntax likely breaks across Tailwind versions
All four column divs use `*:[div]:w-full *:[div]:max-w-full` as a child selector override. This syntax — applying a variant selector to a specific element type — is not standard Tailwind v3 syntax and is likely a custom or experimental pattern. The intent appears to be constraining child `<div>` widths, but the selector is fragile: it depends on the exact element type rendered by each demo component and will silently do nothing if any component wraps its root in a `<section>`, `<form>`, or `<fieldset>`.
Why it matters
Silent layout failures are the worst kind — the selector does nothing for non-div roots, so layout constraints appear to work in dev but break when a child component's root element changes, making maintenance cost grow invisibly.
Fix
Use a direct class on child components or a CSS custom property override rather than element-type child selectors that depend on implementation details of sub-components.
<div className="flex flex-col gap-6 *:[div]:w-full *:[div]:max-w-full"><div className="flex flex-col gap-6 [&>*]:w-full [&>*]:max-w-full">Want this on your repo?
Rams reviews your next PR automatically and posts inline fix suggestions.
Review my public repoFreeUX
Duplicate actions in toolbar and dropdown create silent inconsistency
Two actions appear in both the visible toolbar and the 'More Options' dropdown: - 'Archive' exists as a standalone `Button` and as a `DropdownMenuItem` under `MailCheckIcon` - 'Snooze' exists as a standalone `Button` and as a `DropdownMenuItem` with `ClockIcon` The dropdown is meant to be the overflow for actions that don't fit — having the same items in both places implies neither location is authoritative.
Why it matters
When a user triggers 'Archive' from the toolbar and then opens the dropdown and sees 'Archive' again, they lose confidence in what each action does and whether they produce the same result. Action duplication signals unfinished information architecture.
Fix
Each action belongs in exactly one place — either inline or in the overflow menu, not both. Remove 'Archive' and 'Snooze' from the dropdown since they already have prominent inline slots.
<DropdownMenuGroup>
<DropdownMenuItem>
<MailCheckIcon />
Mark as Read
</DropdownMenuItem>
<DropdownMenuItem>
<ArchiveIcon />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<ClockIcon />
Snooze
</DropdownMenuItem><DropdownMenuGroup>
<DropdownMenuItem>
<MailCheckIcon />
Mark as Read
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<CalendarPlusIcon />
Add to Calendar
</DropdownMenuItem>Hierarchy
Typography
Color
Spacing
Motion
Working well
- ✓Keeping 'RtlComponentsContent' as a separate function that reads from 'useLanguageContext' and then wrapping it in 'RtlComponents' with the providers is clean separation of concerns — the content component is never responsible for its own context setup, which makes it testable in isolation and avoids the common pattern of nesting providers inside the component that consumes them.
- ✓The `ButtonGroup` nesting strategy — outer group as a flex container, inner groups as logical clusters — produces clean visual separation between navigation (back arrow), content actions (Archive/Report), and contextual actions (Snooze/More). This mirrors how email clients like Gmail segment toolbar actions and gives users a reliable mental model for what each zone does.
- ✓Using `order-first` on the fourth column for mobile so `NotionPromptForm` and `ButtonGroupDemo` appear at the top of the single-column view is the right instinct — putting the most complex, interactive demos first on mobile means the page has a focal point rather than opening with utility components. The execution has a bug at `lg`, but the ordering intention is sound.
- ✓The 'order-first' + 'xl:order-last' pattern for the NotionPromptForm column shows correct mobile-first thinking — the most illustrative demo component leads on small screens where column count is 1, then shifts to the end at 4-col where it serves as a natural fourth panel. This is the right instinct even if the lg gap needs fixing.
Score your own repo with Rams.
Free on public repos. Reviews run on every pull request and post inline fix suggestions.
Review my public repoFree