shadcn-ui on GitHub

shadcn-ui/ui

Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.

30 files reviewed·May 13, 2026

Top fix

Add aria-hidden visually-hidden warnings to all external new-tab links.

See the fix

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

92/100

Accessibility

4 serious
Accessibility·packages/shadcn/test/fixtures/frameworks/next-app-src/src/app/page.tsx:62Serious

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`}>
Accessibility·packages/shadcn/test/fixtures/frameworks/t3-app/src/app/page.tsx:11Serious

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"
>
Accessibility·apps/v4/app/(app)/examples/rtl/components/index.tsx:63Serious

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>
Accessibility·apps/v4/app/(app)/(root)/components/appearance-settings.tsx:66Serious

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

Components

1 serious
Components·apps/v4/app/(app)/(root)/components/index.tsx:25Serious

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

UX

1 serious
UX·apps/v4/app/(app)/(root)/components/button-group-demo.tsx:67Serious

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

✓ No issues found

Typography

✓ No issues found

Color

✓ No issues found

Spacing

✓ No issues found

Motion

✓ No issues found

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