excalidraw on GitHub

excalidraw/excalidraw

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

30 files reviewed·May 13, 2026

Top fix

Replace the div close button with a keyboard-accessible button element.

See the fix

Verdict

Excalidraw's docs site is a Docusaurus starter that was never customized — placeholder copy, default illustrations, and bare-bones accessibility all shipped together. The close button being keyboard-inaccessible is the sharpest signal that accessibility was never part of the definition of done.

Files Rams reviewed

dev-docs/src/pages/index.tsx

examples/with-nextjs/src/app/layout.tsx

examples/with-nextjs/src/app/page.tsx

dev-docs/src/components/Homepage/index.tsx

dev-docs/src/pages/index.module.css

examples/with-nextjs/src/pages/excalidraw-in-pages.tsx

packages/excalidraw/components/Stats/index.tsx

dev-docs/src/components/Homepage/styles.module.css

examples/with-script-in-browser/components/CustomFooter.tsx

examples/with-script-in-browser/components/ExampleApp.scss

excalidraw-app/components/AI.tsx

excalidraw-app/components/AppMainMenu.tsx

excalidraw-app/components/AppSidebar.tsx

excalidraw-app/components/AppWelcomeScreen.tsx

excalidraw-app/components/DebugCanvas.tsx

excalidraw-app/components/ExportToExcalidrawPlus.tsx

excalidraw-app/components/TopErrorBoundary.tsx

packages/excalidraw/components/Actions.scss

packages/excalidraw/components/ActiveConfirmDialog.tsx

packages/excalidraw/components/BraveMeasureTextError.tsx

packages/excalidraw/components/Button.tsx

packages/excalidraw/components/ButtonIcon.tsx

packages/excalidraw/components/Card.scss

packages/excalidraw/components/CheckboxItem.scss

packages/excalidraw/components/ColorPicker/ColorInput.tsx

packages/excalidraw/components/ColorPicker/ColorPicker.scss

packages/excalidraw/components/ColorPicker/ColorPicker.tsx

packages/excalidraw/components/ColorPicker/CustomColorList.tsx

packages/excalidraw/components/ColorPicker/Picker.tsx

packages/excalidraw/components/ColorPicker/PickerColorList.tsx

91/100

UX

1 critical3 serious
UX·dev-docs/src/components/Homepage/index.tsx:13Critical

Placeholder copy ships unchanged — page describes Docusaurus, not the product

All three FeatureList entries use the default Docusaurus starter text: - "Easy to Use" — describes Docusaurus installation - "Focus on What Matters" — references moving files into `docs` - "Powered by React" — describes Docusaurus' extension model This copy describes the framework, not the product this documentation site is for.

Why it matters

Every visitor landing on this homepage reads copy about a third-party framework instead of the product they came to learn about. It actively erodes trust and signals the docs site is unfinished.

Fix

Replace placeholder content with copy that describes the actual product's value propositions.

const FeatureList: FeatureItem[] = [
  {
    title: "Easy to Use",
    Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
    description: (
      <>
        Docusaurus was designed from the ground up to be easily installed and
        used to get your website up and running quickly.
      </>
    ),
  },
  {
    title: "Focus on What Matters",
    Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
    description: (
      <>
        Docusaurus lets you focus on your docs, and we&apos;ll do the chores. Go
        ahead and move your docs into the <code>docs</code> directory.
      </>
    ),
  },
  {
    title: "Powered by React",
    Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
    description: (
      <>
        Extend or customize your website layout by reusing React. Docusaurus can
        be extended while reusing the same header and footer.
      </>
    ),
  },
];
// Replace with actual product feature copy and relevant SVGs
const FeatureList: FeatureItem[] = [
  {
    title: "Your Feature Title",
    Svg: require("@site/static/img/your-feature-1.svg").default,
    description: (
      <>
        Describe the actual value this product delivers to the user.
      </>
    ),
  },
  // ...
];
UX·examples/with-nextjs/src/app/page.tsx:18Serious

Router-switch link and page title share no visual separation

"Switch to Pages router" (a navigation action) and "App Router" (a page title / <h1>) are adjacent with no visual grouping or spacing between them. The link reads as part of the heading sequence rather than a distinct navigation element.

Why it matters

Users scanning the page can't immediately distinguish navigation from content, which flattens the entry hierarchy and makes the page's purpose less scannable.

Fix

Separate navigation controls from page content visually — use a distinct container or spacing so the link reads as a nav affordance, not a subtitle.

<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<a href="/excalidraw-in-pages" className="text-sm underline">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
UX·dev-docs/src/pages/index.tsx:19Serious

Single CTA with no secondary path leaves evaluators stranded

The `HomepageHeader` renders one button — 'Get started' — linking directly to `/docs`. There is no secondary link to a changelog, GitHub repo, or package overview. Users who want to evaluate scope before diving into docs have nowhere to go except the primary CTA, which makes the page feel like a dead end for anyone not already committed.

Why it matters

Docs landing pages serve two audiences: integrators ready to start, and evaluators scanning scope. A single entry point serves only the first group and forces the second to bounce back to wherever they came from.

Fix

Provide a secondary ghost/outline link alongside the primary CTA to give evaluators a meaningful alternative path.

<Link className="button button--secondary button--lg" to="/docs">
  Get started
</Link>
<Link className="button button--secondary button--lg" to="/docs">
  Get started
</Link>
<Link className="button button--outline button--secondary button--lg" to="https://github.com/excalidraw/excalidraw">
  View on GitHub
</Link>
UX·examples/with-nextjs/src/pages/excalidraw-in-pages.tsx:17Serious

"Switch to App router" link lacks any visual affordance that it is a link

The `<a href="/">Switch to App router</a>` element has no className, no styling from `common.scss` that would mark it as a link (underline, color, hover state). It renders as unstyled browser-default text.

Why it matters

Without a visual affordance, users scanning the page do not identify this as an interactive navigation element, and the page-to-page wayfinding this link provides goes unnoticed.

Fix

Apply explicit link styling — at minimum an underline and a distinct color — so the element reads as a navigation affordance.

<a href="/">Switch to App router</a>
<a href="/" className="switch-router-link">Switch to App router</a>
95/100

Accessibility

1 critical1 serious
Accessibility·packages/excalidraw/components/Stats/index.tsx:198Critical

Close button is an inaccessible div — keyboard users cannot dismiss the panel

The panel's close control is rendered as: - `<div className="close" onClick={onClose}>{CloseIcon}</div>` This is a plain div with a click handler. It has no `role`, no `tabIndex`, no keyboard handler, and no accessible name. The icon renders no visible label.

Why it matters

Keyboard-only users and screen reader users cannot reach or activate this control. The panel becomes a trap — it can be opened but not closed without a mouse.

Fix

Replace interactive divs with <button> elements; icon-only buttons need an aria-label.

<div className="close" onClick={onClose}>
  {CloseIcon}
</div>
<button className="close" onClick={onClose} aria-label={t("buttons.close")}>
  {CloseIcon}
</button>
Accessibility·examples/with-nextjs/src/app/layout.tsx:7Serious

No color-scheme meta means browser chrome mismatches dark-mode pages

The `<html>` element has `lang="en"` but no `color-scheme` declaration. If any page in this app supports dark mode, browser-native controls (scrollbars, form inputs, date pickers) will render in light mode regardless of the page theme.

Why it matters

Native browser UI elements (scrollbars, selects, checkboxes) are styled by the OS based on `color-scheme`. Without it, a dark-mode page gets light scrollbars and input backgrounds — a jarring mismatch that signals an unfinished experience.

Fix

Set `color-scheme` on the root `<html>` element so browser chrome matches the page theme.

<html lang="en">
<html lang="en" className="antialiased" style={{ colorScheme: 'light dark' }}>

Hierarchy

✓ No issues found

Typography

✓ No issues found

Color

✓ No issues found

Spacing

✓ No issues found

Components

✓ No issues found

Motion

✓ No issues found

Working well

  • Separating `Stats` (the stateful shell that reads live scene data) from `StatsInner` (the memoized render tree) is a clean architectural decision. The outer component absorbs the re-render cost of reading `sceneNonce` and `selectedElements` on every tick, while the inner component only rerenders when the derived data actually changes. This pattern keeps the expensive JSX tree stable without requiring complex selector logic.
  • The custom `memo` comparator on `StatsInner` is precise and intentional — it compares `sceneNonce`, `selectedElements` reference, `stats.panels` bitmask, `gridModeEnabled`, `gridStep`, and `croppingElementId` individually instead of doing a shallow-equal of the whole props object. This is exactly right for a panel that re-renders on every canvas mutation: it opts into exactly the updates it needs and nothing more.
  • The `lang="en"` attribute on `<html>` is correctly placed at the root layout level. This is the right call — setting it here means every page in the app inherits the correct language declaration without needing to remember it per-route, which directly benefits screen reader pronunciation and search indexing.
  • Using `siteConfig.title` and `siteConfig.tagline` for the hero text instead of hardcoding strings is smart system design — it keeps the heading in sync with the Docusaurus config as the product name evolves, and prevents the page from diverging from the rest of the site's identity.

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