excalidraw/excalidraw
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
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
UX
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'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.
</>
),
},
// ...
];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>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>"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>Accessibility
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>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
Typography
Color
Spacing
Components
Motion
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