
Resend Design Skills
- 1.1k installs
- 39 repo stars
- Updated July 13, 2026
- resend/design-skills
resend-design-skills is an index skill routing agents to Resend brand, design system, marketing page, and audit sub-skills.
About
The resend-design-skills index routes Claude Code agents to four Resend-specific design skills covering brand guidelines, product UI design system, marketing pages, and dashboard design audits. resend-brand applies colors, typography, and visual identity to external marketing artifacts. resend-design-system documents UI component APIs, design tokens, composition patterns, and UX heuristics for src/ui work. marketing-pages governs creating or editing pages under src/app/(website)/ with SEO metadata and public primitives. design-audit checks dashboard alignment for missing docs, token misuse, deprecated components, and pattern candidates, including a scheduled Monday routine. Each sub-skill lives in its own folder with SKILL.md, references, and tests documented in the index structure.
- Index routes to resend-brand, design-system, marketing-pages, and design-audit skills.
- Design-system skill covers tokens, components, heuristics, and src/ui patterns.
- Marketing-pages skill targets src/app/(website)/ structure and SEO metadata.
- Design-audit skill flags token misuse and deprecated component usage.
- Folder layout documents references for components, rubrics, and test suites.
Resend Design Skills by the numbers
- 1,095 all-time installs (skills.sh)
- +15 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #372 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
What resend-design-skills says it does
Routes to brand guidelines, visual identity, UI components, design tokens, and marketing page patterns
npx skills add https://github.com/resend/design-skills --skill resend-design-skillsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 39 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 13, 2026 |
| Repository | resend/design-skills ↗ |
Which Resend design skill should handle this brand, UI, marketing page, or audit task?
Route Resend design tasks to brand, design-system, marketing-pages, or design-audit sub-skills.
Who is it for?
Developers working in the Resend codebase on UI, marketing, or design alignment tasks.
Skip if: Skip for non-Resend projects without their design system or marketing structure.
When should I use this skill?
User needs Resend design resources, brand guidelines, UI components, or dashboard design audit.
What you get
Correct sub-skill selected with Resend-specific tokens, components, and page patterns applied.
- On-brand UI layouts
- Marketing page patterns
- Token-aligned component specs
By the numbers
- Published at version 1.0.0
- Includes resend-brand and resend-design-system sub-skills in the collection table
Files
Resend Brand Guidelines
Core Colors
| Name | Hex |
|---|---|
| Resend Black | #000000 |
| Resend White | #FDFDFD |
Semantic Colors
| Scale | Background | Foreground | Usage |
|---|---|---|---|
| Gray | #16171AEB | #FDFEFFA6 | Structure, hierarchy, and subtle separation |
| Red | #FF173F2D | #FF9592 | Critical states and irreversible actions |
| Amber | #FA820022 | #FFCA16 | Caution and pending states |
| Green | #22FF991E | #46FEA5D4 | Success and completion |
| Blue | #0077FF3A | #70B8FF | Interactive and informational elements |
Typography
| Font | Role |
|---|---|
| Domaine Display Narrow | Display headlines (never in product UI) |
| Favorit | Headings & titles |
| Inter | Body text |
| CommitMono | Code |
Typography Rules
- Use sentence case everywhere (headings, buttons, labels, navigation)
- Never use the Domaine font in bold
- Never use monospace for titles or body copy
- Never replace brand fonts with alternatives
Typography Scale
Display
| Style | Font | Size/Line | Letter Spacing |
|---|---|---|---|
| display/large | Domaine Display Narrow | 96/96 | -0.96px |
| title | Resend Favorit | 60/64 | -2.8px |
| small | Domaine Display Narrow | 72/72 | -0.77px |
Body
| Style | Font | Weight | Size/Line |
|---|---|---|---|
| xlarge | Resend Favorit | Regular | 24/32 |
| large | Inter | Regular/Medium | 18/28 |
| medium | Inter | Regular/Medium/Semi Bold | 16/24 |
| small | Inter | Regular | 14/20 |
| code | CommitMono | Regular | 14/20 |
Logo
Wordmark
https://cdn.resend.com/brand/resend-wordmark-white.svghttps://cdn.resend.com/brand/resend-wordmark-white.pnghttps://cdn.resend.com/brand/resend-wordmark-black.svghttps://cdn.resend.com/brand/resend-wordmark-black.png
Lettermark
https://cdn.resend.com/brand/resend-icon-white.svghttps://cdn.resend.com/brand/resend-icon-white.pnghttps://cdn.resend.com/brand/resend-icon-black.svghttps://cdn.resend.com/brand/resend-icon-black.png
Clearspace
Minimum clear space = 1/2 cap height on all sides
Minimum Size
- Preferred: 24px height
- Extreme cases: 16px height minimum
Logo Restrictions
Never: rotate, apply effects, outline, slant/stretch, use multiple colors, use low resolution, combine symbol+wordmark, modify proportions
Cube Element
Secondary brand symbol. Never use as: primary logo, navigation element, or with modified geometry/colors.
Gradients
| Name | Value |
|---|---|
| Font gradient | linear-gradient(97deg, #ffffff 30%, rgba(255,255,255,0.50) 100%) |
| Smooth gradient | linear-gradient(96deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.10) 100%) |
| Border | linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.05) 100%) |
| Rainbow border | linear-gradient(90deg, rgba(2,252,239,0.44) 0%, rgba(255,181,43,0.44) 50%, rgba(160,43,254,0.44) 100%) |
Effects
| Name | Value |
|---|---|
| Glass blur | backdrop-filter: blur(25px) |
Textures
- Noise: Hero backgrounds, atmospheric depth
https://resend.com/static/product-pages/noise.png
Backgrounds
Brand wallpapers available at: https://resend.com/wallpapers
Layout Patterns
| Name | Description |
|---|---|
| Right Object Scene | Small label top-left, title top-left (2 lines), 3D object right |
| Interface Scene | Label top-left, title bottom-left (2 lines), UI screenshot background |
| Text Only Scene | Title top-left, 3D abstract scene fills background |
| Text Only Background | Large title centered, subtle texture/gradient background |
| Text Only Subtle | Small centered text (2 lines), minimal dark background |
| Big Number | Large display number centered (Domaine), small label below |
Common patterns:
- Label/category always small, top-left or top-center
- Titles use 2-line breaks for rhythm
- Titles are never longer than 3 lines.
- Objects positioned right, left, or as full background
- Dark backgrounds with subtle depth
Design Principles
1. Dark-first design philosophy 2. Sharp contrast between black and light 3. Precision and focus over decoration 4. Accent colors communicate state, not style 5. Simple, stable, intentional forms
Linear Delivery Playbook
One ticket per distinct finding or suggestion. Each ticket covers one rule violation type, one missing doc, one pattern candidate, or one rubric candidate — and lists every affected file in its body.
Error-severity findings always get a ticket. They are never skipped due to curation limits, duplicate-title heuristics, or run-to-run de-duplication. The only exception is a pre-existing open ticket for the same rule_id — in that case, comment with the new occurrences instead of creating a duplicate. A cancelled ticket does NOT suppress an error: create a new ticket regardless.
Tools
linear:list_issues— check for existing or cancelled issues before creatinglinear:create_issue— open a ticket for a findinglinear:create_comment— add context to an existing open ticket
What produces a ticket
| Source | Ticket title shape | One ticket per |
|---|---|---|
missing_docs | "Document the {component} component" | Component |
violations (structural) | "Replace {raw element} with {primitive}" / "Fix token misuse in {area}" | rule_id |
violations (use-sentence-case) | "Fix sentence case in dashboard copy" | rule_id |
violations (copy-typo) | "Fix typos in dashboard copy" | rule_id |
violations (avoid-vague-cta) | "Replace vague CTAs in dashboard" | rule_id |
violations (brand-terminology) | "Fix brand terminology in dashboard copy" | rule_id |
violations (brand-voice) | "Fix tone and voice in dashboard copy" | rule_id |
pattern_candidates | "Document the {proposed_name} pattern" | Proposed pattern |
rubric_candidates | "Add {proposed_rule_id} rule to design skill" | Proposed rule |
Violations with the same rule_id across multiple files → one ticket listing all affected files. Do not create a ticket per file.
Preflight — Check the triage backlog
Before creating any tickets, evaluate the current Triage backlog so the audit doesn't pile another overwhelming batch on top of unresolved work.
linear:list_issues
filter: { label: "design-audit", state: "Triage" }
team: "Design"- 0–9 pending in Triage: proceed normally.
- 10 or more pending in Triage: do not create new
warn/infotickets, pattern candidates, or rubric candidates this run. Comment new occurrences on existing open tickets where applicable, and note in the report: "Triage backlog at {N} — deferred {M} non-error tickets until backlog drops below 10." - Errors are exempt:
error-severity findings are always filed regardless of backlog size (per the rule at the top of this doc).
For each ticket to create
Step 1 — Check for a duplicate or cancelled ticket
Before creating, call:
linear:list_issues
filter: { label: "design-audit" }
team: "Design"- An open ticket with the same title or same rule_id already exists → comment on it with any new affected files. Do not create a duplicate.
- A cancelled ticket for this same finding exists → skip it entirely. The team already decided not to act on it. Move to the next finding.
- No matching ticket → create it (Step 2).
Step 2 — Create the ticket
linear:create_issue
title: {title from table above}
team: "Design"
project: "Design Audit" # https://linear.app/resend/project/design-audit-59b6c51f2dee
labels: ["design-audit"]
state: "Triage"
priority: {see Priority rules}
body: {see Body format}Priority rules
| Condition | Priority |
|---|---|
error severity | Urgent |
warn severity | Medium |
info severity, pattern candidate, rubric candidate | Low |
Body format
Plain markdown only. No HTML tags.
{one sentence describing the problem}
**Rule:** `{rule_id}` · [Design reference]({design_ref})
**Affected files:**
- `{file}:{line}` — {suggestion}
- `{file}:{line}` — {suggestion}
**Suggestion:** {suggestion}For pattern and rubric candidates:
{rationale}
**Occurrences:**
- `{file}:{line}`
- `{file}:{line}`
**Suggested fix:** {suggested_fix}Failure handling
If any Linear MCP call fails: 1. Print the full error to the session transcript. 2. Print the list of tickets that were not created. 3. Do not retry more than once.
Report Format
Curation rules — read before building the report
Do not list every finding. The goal is an actionable ticket, not an exhaustive log.
1. Errors are always logged in full. Every error-severity finding must appear in the ticket body and get its own Linear ticket — no exceptions, no count summaries. They are never folded into "N more findings". 2. Top findings (warn/info): After errors, pick the 5 most impactful non-error findings. Rank by: number of affected files, then warn before info. These are the only non-error findings that appear in the Linear ticket body. 3. Counts only for the rest: Everything outside the top 5 non-error findings is summarised as a count (e.g. "12 more warnings across token misuse and substitution"). 4. Skip info-only runs: If all findings are info severity and there are fewer than 5, note "No significant violations found this week" and skip the findings section entirely. 5. No HTML: Do not use <details>, <summary>, or any HTML tags. Linear renders plain markdown only.
---
JSON structure
Emit this JSON internally (not in the ticket body — use it to build the markdown):
{
"generated_at": "2026-04-20T09:00:00Z",
"commit_sha": "<read from git rev-parse HEAD>",
"summary": {
"errors": 0,
"warnings": 0,
"info": 0,
"coverage_pct": 0
},
"missing_docs": [
{ "ui_file": "src/ui/banner.tsx", "component": "banner" }
],
"violations": [
{
"file": "src/app/(dashboard)/settings/page.tsx",
"line": 42,
"category": "substitution",
"severity": "warn",
"rule_id": "use-button-primitive",
"suggestion": "Use <Button> from @/ui/button",
"design_ref": "/design/components/button"
},
{
"file": "src/app/(dashboard)/domains/page.tsx",
"line": 18,
"category": "copy",
"severity": "warn",
"rule_id": "use-sentence-case",
"suggestion": "\"Add New Domain\" → \"Add new domain\"",
"design_ref": "/design"
},
{
"file": "src/app/(dashboard)/emails/page.tsx",
"line": 73,
"category": "copy",
"severity": "error",
"rule_id": "copy-typo",
"suggestion": "\"Sucessfully sent\" → \"Successfully sent\"",
"design_ref": ""
}
],
"pattern_candidates": [
{
"proposed_name": "empty-state",
"occurrences": ["src/app/(dashboard)/emails/page.tsx:88", "src/app/(dashboard)/domains/page.tsx:34"],
"rationale": "Icon + heading + description + CTA repeated across 3+ dashboard pages without a documented pattern."
}
],
"rubric_candidates": [
{
"proposed_rule_id": "tooltip-accessible-trigger",
"occurrences": ["src/app/(dashboard)/settings/page.tsx:14", "src/app/(dashboard)/domains/page.tsx:62"],
"rationale": "Tooltip trigger consistently lacks aria-label, but no rule currently enforces this.",
"suggested_fix": "Require aria-label on all Tooltip.Trigger elements that wrap icon-only buttons."
}
]
}coverage_pct = (documented_count / total_ui_components) * 100, rounded to one decimal.
---
Markdown template
Use this structure for the Linear ticket body. Fill in only the sections that have findings. Omit empty sections entirely.
**{YYYY-MM-DD} · `{SHA_SHORT}` · Coverage: {COVERAGE_PCT}% ({DOCUMENTED}/{TOTAL})**
{ERRORS} errors · {WARNINGS} warnings · {INFO} info
---
{IF any error findings — always include this section, no matter how many}
### Errors ({COUNT})
{FOR EACH error finding — list every one, no truncation}
- **[{rule_id}]** `{file}:{line}` — {suggestion}
---
### Top findings
{FOR EACH OF THE TOP 5 WARN/INFO FINDINGS, one bullet per finding}
- **[{rule_id}]** `{file}:{line}` — {suggestion} → [{design_ref}]({design_ref})
{IF more warn/info findings beyond top 5}
_{N} more findings ({REMAINING_WARNINGS} warnings, {REMAINING_INFO} info) — run the audit locally to see the full list._
---
### Missing documentation ({COUNT} components)
{LIST component names, one per line, max 10. If more: "and N others."}
- `{component}` — `{ui_file}`
---
### Pattern candidates
{FOR EACH candidate}
- **`{proposed_name}`** — {rationale} ({occurrences count} occurrences)
---
### Rubric candidates
{FOR EACH candidate}
- **`{proposed_rule_id}`** — {rationale} Suggested fix: {suggested_fix}
---
_Design audit skill · [Design system](/design)_Rendering rules:
- Use plain markdown lists and headers only — no HTML
file:linereferences as inline code, not links (Linear doesn't resolve repo links)- Keep the whole body under ~40 lines so it's readable without scrolling
Audit Rubric
Seven categories to check. Categories 1–5 and 7 flag violations against existing rules. Category 6 flags gaps in the rules themselves.
Each violation finding requires: file, line, category, severity, rule_id, suggestion, design_ref.
Setup
Before running any category, read sidebar-data.ts to extract the alias map and ignore list. The alias map normalises component names (e.g. "command" → "combobox", "avatar team" → "avatar"). Apply it when matching src/ui/ filenames against documented-components.json.
---
Category 1 — Missing documentation
Scope: src/ui/ top-level .tsx files (not subdirectories like icons/)
How to check: 1. Glob src/ui/*.tsx to get the list of component files 2. Strip the .tsx extension and convert to lowercase to get a component name 3. Apply the alias map from sidebar-data.ts if applicable 4. Skip any name that appears in the ignore list (_common, layout, shared, page, todo) 5. Check whether the resolved name appears in documented-components.json 6. Any component file NOT in documented-components.json is a finding
Severity: warn Rule ID: missing-docs Design ref: /design (component documentation guide) Suggestion: "Add a documentation page at src/app/(internal)/design/components/<name>/page.tsx following the component-documentation-guide.md template."
---
Category 2 — Component substitution
Scope: src/app/(dashboard)*/**/*.tsx and src/components/**/*.tsx
What to grep for:
| Pattern | Rule ID | Suggestion |
|---|---|---|
<button (HTML element, not component) | use-button-primitive | Use <Button> from @/ui/button |
<input | use-text-field-primitive | Use TextField.Input from @/ui/text-field/text-field |
<select | use-select-primitive | Use Select.Root/Trigger/Content/Item from @/ui/select |
<dialog | use-dialog-primitive | Use Dialog.Root/Content from @/ui/dialog |
<textarea | use-text-area-primitive | Use TextField.Input with asChild or a dedicated textarea primitive |
Also flag files that appear to re-implement primitives already in src/ui/. The list is derived from `documented-components.json` (loaded in Step 1) so it stays in sync as new components are documented.
Rule ID: reimplements-primitive
How to check: 1. Build the set of documented component names from documented-components.json, applying the alias map from sidebar-data.ts in reverse (e.g. combobox is also matched by an export named Command). 2. Remove names on the generic denylist below — these are too common to enforce without high false-positive rates:
Card,Text,Heading,Tag,Badge,Avatar,Spinner,Label,Link,Image,Icon
3. For each remaining name, search files outside `src/ui/` for a top-level export whose name matches case-insensitively (export function <Name>, export const <Name> =, export default function <Name>, export { <Name> }). 4. A match is a finding only if the file does not also import { <Name> } from '@/ui/<name>' — files that wrap or re-export the primitive are not re-implementations. 5. TextInput is an additional alias for TextField — keep matching it even though it isn't in documented-components.json.
Severity: warn Suggestion: "Use <Name> from @/ui/<name> instead of re-implementing it. If a wrapper is genuinely needed, import the primitive and extend it." Design ref: /design/components/<name>
Hand-rolled button state management — `use-state-prop`
Files that import Button or IconButton from @/ui/ but manage disabled or loading states manually instead of using the state prop.
Detect when a file matches any of these signals:
1. A Button or IconButton has disabled={<expression>} where the expression is not a static true/false — e.g. disabled={isLoading}, disabled={disabled || isLoading}. 2. A Button or IconButton has appearance conditionally set based on a loading or disabled variable — e.g. appearance={isLoading ? 'gray' : 'fade'}, appearance={isSubmitDisabled ? 'gray' : 'fade'}. 3. A Button or IconButton has className conditions that gate accent/interactive styles on a loading or disabled variable — e.g. !disabled && !isLoading && 'bg-accent!', !isSubmitDisabled && 'text-on-accent!'. 4. An IconButton uses asChild and wraps a <button disabled={...}> child to work around the component's own disabled handling.
Severity: warn Rule ID: use-state-prop Suggestion: Replace manual disabled/loading wiring with the state prop: state="disabled" or state="loading". The state prop is self-sufficient — it handles the visual style, prevents interaction, and does not need to be combined with disabled={}, appearance overrides, or conditional className guards. Design ref: /design/components/button
---
Hand-rolled dropdown menus — `use-dropdown-tokens`
Files outside src/ui/ that compose their own dropdown / menu surface instead of reusing DropdownMenu from @/ui/dropdown-menu or the shared dropdown.* token groups in src/ui/shared.ts.
Detect when a file matches both of these signals: 1. Imports or renders Popover.Content (from @/ui/popover) — or a floating div with max-h-[ and overflow-y-auto — as the menu surface. 2. Contains child item elements (<button>, <a>, or role="menuitem") whose className re-creates dropdown item styles: a combination of rounded-(lg|xl), px-2, and any of bg-gray-a2, text-gray-9, text-gray-10, text-gray-a10, or selected-state pairs like bg-gray-a2 text-gray-10.
Severity: warn Suggestion: Compose with DropdownMenu.Root/Content/Item from @/ui/dropdown-menu. If a custom surface is genuinely required (e.g. a typeahead anchored to an editor), reuse the dropdown.content.appearance, dropdown.item.sizing, and dropdown.item.appearance.gray token groups exported from @/ui/shared instead of redefining the classes inline. Design ref: /design/components/dropdown-menu
---
Category 3 — Token misuse
Scope: src/app/(dashboard)*/**/*.tsx and src/components/**/*.tsx
What to grep for:
| Pattern | Description | Rule ID | Severity |
|---|---|---|---|
w-\[[0-9] | Arbitrary width (e.g. w-[13px]) | use-sizing-scale | info |
h-\[[0-9] | Arbitrary height | use-sizing-scale | info |
text-\[[0-9] | Arbitrary font size (e.g. text-[14px]) | use-text-scale | warn |
bg-\[# | Hex color background | use-color-token | warn |
text-\[# | Hex color text | use-color-token | warn |
border-\[# | Hex color border | use-color-token | warn |
| `(bg\ | text\ | border\ | ring\ |
Suggestion for color violations: Replace hex literals and deprecated Tailwind default palettes (slate, zinc, neutral, stone, emerald, teal, sky, indigo, purple, fuchsia, pink, rose, amber, lime) with the Resend semantic tokens defined in design-system/references/design-tokens.md. Map by intent, not by name:
slate/zinc/neutral/stone→text-default,text-muted,bg-elevated,border-default(orgray-*/sand-*primitives as fallback)emerald/teal/lime→text-success,bg-success,border-successsky→text-info,text-link(orblue-*/cyan-*primitives)indigo/purple/fuchsia→violet-*(no semantic family currently)pink/rose→text-error,bg-error,border-erroramber→text-warning,bg-warning,border-warning
Suggestion for sizing violations: Use the sizing scale defined in design-system/SKILL.md or a standard Tailwind step. Design ref: /design (design tokens section)
3a — Prefer semantic tokens over primitives
Rule ID: prefer-semantic-token · Severity: warn
The codebase ships a semantic color layer in src/styles/tokens.css (see design-system/references/design-tokens.md). Components should reach for intent-named tokens first; raw primitives are escape hatches for cases where no semantic role fits.
What to flag: Usage of these primitives where a semantic token exists:
| Primitive class | Suggest semantic |
|---|---|
bg-gray-1 | bg-subtle |
bg-gray-2 | bg-elevated |
border-gray-2 | border-subtle |
border-gray-3 | border-default |
border-gray-a3 | border-interactive |
bg-gray-a2 | bg-interactive |
bg-gray-a3 | bg-interactive-hover (or ring-focus for focus rings) |
text-gray-8 | text-muted |
text-gray-11 | text-default |
text-gray-12 | text-emphasis |
bg-red-3 | bg-error |
bg-red-5 | bg-error-hover |
border-red-6 | border-error |
border-red-4 | border-error-subtle |
text-red-11 | text-error |
ring-red-7 | ring-error |
bg-yellow-3 | bg-warning |
border-yellow-6 | border-warning |
border-yellow-4 | border-warning-subtle |
text-yellow-11 | text-warning |
bg-green-3 | bg-success |
border-green-6 | border-success |
border-green-4 | border-success-subtle |
text-green-11 | text-success |
bg-blue-3 | bg-info |
border-blue-4 | border-info-subtle |
text-blue-11 | text-info |
text-blue-9 | text-link |
border-blue-9 | border-link |
ring-blue-7 | ring-link |
Do not flag:
- Softer tiers without a semantic equivalent —
text-red-10,text-red-9,text-yellow-10,text-blue-10, etc. (used by fade-style buttons and subtle states). - Mid-scale grays —
gray-4throughgray-7. These have no semantic name and are intentional escape hatches. - Theme-aware overrides (
dark:variants on a semantic token) — let the token handle theme switching internally. - Event lifecycle colors consumed via schemas (mauve, cyan, violet, sand for
scheduled/received/clicked/draftstates).
Suggestion: Replace <primitive> with <semantic>. The primitive can still be reached via the semantic token under the hood — components stop knowing which step of which scale to use.
Design ref: /design/tokens (semantic layer section)
---
Category 4 — Deprecated component usage
Scope: src/app/(dashboard)*/**/*.tsx
How to check: 1. For each component in documented-components.json, read its design page at src/app/(internal)/design/components/<name>/page.tsx 2. Search that file for a Deprecated section header or a deprecated prop example 3. If found, note the component name as deprecated 4. Grep src/app/(dashboard)* for from '@/ui/<deprecated-component-name>' imports 5. Each importer is a finding
Severity: warn Rule ID: deprecated-component Suggestion: "Check the /design/components/<name> page for the recommended replacement." Design ref: /design/components/<name>
---
Category 5 — Pattern candidates
Scope: src/app/(dashboard)*/**/*.tsx
How to identify: 1. Read documented-patterns.json (currently [] — no patterns documented yet) 2. Look for structural compositions that repeat across ≥ 3 dashboard files, such as:
- Empty state layouts (icon + heading + description + action button)
- Filter bars (search input + dropdown filters + tag list)
- Confirm dialogs with title + description + cancel/confirm buttons
- Table toolbar patterns (bulk action bar + count + primary action)
- Settings sections (heading + description + control on the right)
3. A candidate must NOT already exist in documented-patterns.json
Output: Emit as pattern_candidates in the report, not as violations. These are proposed improvements, not errors.
Fields required:
proposed_name— a slug name for the pattern (e.g.empty-state,confirm-dialog)occurrences— array offile:linewhere the pattern appearsrationale— one sentence on why this should be a documented pattern
---
Category 6 — Rubric candidates
Scope: Everything observed during the full audit run.
What to look for:
A rubric candidate is a recurring misuse or anti-pattern that appears in ≥ 3 dashboard files but is not covered by any existing rule in this rubric. Examples:
- A
Tooltipalways composed without anaria-labelon its trigger - A layout idiom (e.g. a card header with icon + title + action) that bypasses a primitive but also doesn't match any existing substitution rule
- A CSS utility or custom class recreating something the DS already provides, but not caught by the token-misuse grep patterns
- Consistent
classNameoverrides on a primitive that suggest a missing variant - A heuristic from
design-system/references/heuristics/repeatedly drifted from (e.g. dialogs used where steppers fit, hidden controls where disabling would be clearer, columns shown that are mostly empty). Cite the heuristic file as thedesign_refand frame the candidate as a recurring judgment call, not a hard violation.
How to identify: 1. While running categories 1–5, note anything that feels like a systemic problem but doesn't fit an existing rule 2. Look for it in at least 3 files before flagging — one-offs are noise 3. Confirm it's not already covered by an existing rule ID
Output: Emit as rubric_candidates in the report, not as violations. These are proposed additions to the design skill or this rubric.
Fields required:
proposed_rule_id— a slug for the new rule (e.g.tooltip-accessible-trigger,card-header-primitive)occurrences— array offile:lineshowing the patternrationale— one sentence on why this should be a rulesuggested_fix— what the rule should prescribe (a primitive to use, a prop to set, a pattern to follow)
---
Category 7 — Copy & brand voice
Scope: src/app/(dashboard)*/**/*.tsx
What to check: User-facing strings — JSX text nodes, placeholder, aria-label, title prop values, and string arguments to description, label, and similar props.
How to run: Two passes.
1. Grep pass — fast, deterministic checks (rules 7a, 7c, 7d, 7e partial) 2. LLM pass — glob src/app/(dashboard)/*/page.tsx plus key nested routes to get ~15 representative pages. Read each file, extract hardcoded strings with their line numbers, then apply the remaining rules (7b, 7d partial, 7e).
Each violation finding requires file, line, category: "copy", severity, rule_id, suggestion, and design_ref.
---
7a — Sentence case
Rule ID: use-sentence-case · Severity: warn
Resend uses sentence case everywhere: headings, buttons, labels, nav items, error messages. Title Case is a violation.
Grep for common patterns where 2+ consecutive words are title-cased inside JSX text nodes (not proper nouns):
>Save Changes,>Get Started,>Learn More,>View All,>Add New,>Create New,>Sign In,>Sign Up,>Log Out,>Read More,>Try Again
LLM pass: Flag any button label, heading, or CTA with 2+ title-cased words that are not proper nouns (product names, company names, acronyms).
Suggestion: Use sentence case — e.g., "Save Changes" → "Save changes", "Get Started" → "Get started" Design ref: /design (brand guidelines — typography rules)
---
7b — Typos and misleading copy
Rule ID: copy-typo · Severity: error
Grep for known common misspellings inside string literals and JSX text:
recieve→receiveoccured/occurance→occurred/occurrenceseperate→separateexistance→existenceprivelege/priviledge→privilegedefinitly→definitelysucessful/succesful→successful
LLM pass: For each sampled file, flag any clear spelling errors, double spaces, grammatically broken phrases, or misleading copy — text that describes a feature, action, or state incorrectly (e.g. a button labelled "Delete" that actually archives, or an error message that names the wrong field).
Suggestion: Fix the specific typo, grammar issue, or inaccurate description.
---
7c — Vague CTAs
Rule ID: avoid-vague-cta · Severity: info
CTAs should be specific and action-oriented. Generic text fails clarity and accessibility.
Grep for these patterns as complete (or near-complete) JSX text node content:
>Click here</>Click Here<>here</>Here<as standalone link text>Read more</>Read More<as a standalone CTA
Suggestion: Replace with a specific action — "Click here" → "View API keys", "Read more" → "Read about webhooks"
---
7d — Brand terminology
Rule ID: brand-terminology · Severity: warn
Key terms must be spelled and capitalised consistently.
Grep for these patterns in JSX text nodes and string literals:
| Pattern | Correct | Note |
|---|---|---|
e-mail / E-mail | email / Email | No hyphen |
RESEND in UI text | Resend | Product name is not all-caps |
Re-send | Resend | No hyphen in product name |
LLM pass: Review sampled strings for inconsistent use of feature names and product terms (e.g. "API Key" vs "API key", "web hook" vs "webhook").
Suggestion: Use the correct form: email, Resend, webhook, API key. Design ref: /design (brand guidelines)
---
7e — Tone and voice
Rule ID: brand-voice · Severity: info
Resend's voice is direct, minimal, and developer-focused. Flag copy that drifts into marketing superlatives, excessive enthusiasm, or unnecessary filler.
Grep for these patterns inside JSX text nodes:
- Marketing superlatives:
powerful,amazing,supercharge,seamlessly,effortlessly,incredible,world-class - Exclamation overuse: a
!at the end of a text node in error messages, form labels, or confirmations (celebratory empty-state messages are fine)
LLM pass: For sampled files, flag strings that feel inconsistent with the Resend brand voice — too promotional, too casual, or padded with filler phrases ("just", "simply", "easily").
Suggestion: Rewrite to be direct and specific. "Powerful email delivery" → "Email delivery for developers". Remove filler adverbs. Design ref: /design (brand guidelines — design principles)
Component Catalog
Full API reference for all src/ui/ primitives. Import with @/ui/{name}.
Button
import { Button } from '@/ui/button';
<Button appearance="white" size="2" state="loading" iconLeft={<IconPlus />} shortcut={[SHORTCUTS_VALUES.CMD, SHORTCUTS_VALUES.ENTER]}>Save</Button>| Prop | Type | Default |
|---|---|---|
appearance | `'white' \ | 'gray' \ |
size | `'1' \ | '2'` |
state | `'normal' \ | 'disabled' \ |
iconLeft / iconRight | ReactElement | — |
shortcut | `string \ | [string, string]` |
asChild | boolean | false |
IconButton
Same variants as Button. Always provide aria-label.
import { IconButton } from '@/ui/icon-button';
<IconButton appearance="fade" size="1" aria-label="Close"><IconClose /></IconButton>TextField
Compound component. Always wrap in TextField.Root.
import { TextField } from '@/ui/text-field/text-field';
<TextField.Root>
<TextField.Slot><IconSearch /></TextField.Slot>
<TextField.Input placeholder="Search..." size="2" />
<TextField.Slot>
<TextField.Error message="Required" id="field-error" />
</TextField.Slot>
</TextField.Root>Input props: size 1|2|3, appearance gray|public, state normal|disabled|read-only|invalid, error string. Slots auto-adjust input padding via ResizeObserver. Max one slot before Input, one after.
Heading
import { Heading } from '@/ui/heading';
<Heading as="h2" size="5" color="white" weight="semibold">Title</Heading>as h1-h6. size 1-8 (7-8 use font-display). color white|gray. weight medium|semibold|bold.
Text
import { Text } from '@/ui/text';
<Text as="p" size="2" color="gray">Description</Text>as span|p|strong. size 1-9. color white|gray|red|yellow. weight normal|medium|semibold|bold.
Tag
import { Tag } from '@/ui/tag';
<Tag appearance="green" variant="solid" size="1">Active</Tag>appearance gray|dimgray|green|red|yellow|blue|orange|violet|sand. variant solid|outline. size 1|2.
Banner
import { Banner } from '@/ui/banner';
<Banner appearance="yellow" size="2">Warning message</Banner>appearance gray|green|red|yellow|blue. size 1|2. Auto icon: blue/gray=Info, green=Confetti, red/yellow=Warning.
Select
import * as Select from '@/ui/select';
<Select.Root value={val} onValueChange={setVal}>
<Select.Trigger size="2" appearance="gray" />
<Select.Content>
<Select.Label>Group</Select.Label>
<Select.Item value="a">Option A</Select.Item>
<Select.Separator />
<Select.Item value="b">Option B</Select.Item>
</Select.Content>
</Select.Root>Trigger: size 1|2, appearance gray|ghost, state normal|invalid.
Dialog
import * as Dialog from '@/ui/dialog';
<Dialog.Root>
<Dialog.Trigger asChild><Button>Open</Button></Dialog.Trigger>
<Dialog.Content size="1">
<Dialog.Title>Confirm</Dialog.Title>
<p>Are you sure?</p>
</Dialog.Content>
</Dialog.Root>Content size: 1 (max-w-lg), 2 (1200px), full-screen (80vw/80vh). includeCloseButton defaults true.
Switch & Checkbox
import { Switch } from '@/ui/switch';
<Switch checked={on} onCheckedChange={setOn} disabled={false} />
import { Checkbox } from '@/ui/checkbox';
<Checkbox checked={val} onCheckedChange={setVal} /> // supports 'indeterminate'Tooltip
import * as Tooltip from '@/ui/tooltip';
<Tooltip.Root>
<Tooltip.Trigger asChild><Button>Hover</Button></Tooltip.Trigger>
<Tooltip.Content>Tip text</Tooltip.Content>
</Tooltip.Root>Other Components
| Component | Import | Notes |
|---|---|---|
| Avatar | @/ui/avatar | Compound: Root, Image, Fallback. variant rounded\ |
| Tabs | @/ui/tabs | Namespace: Root, List, Trigger, Content |
| Kbd | @/ui/kbd | appearance gray\ |
| DropdownMenu | @/ui/dropdown-menu | Namespace: Root, Trigger, Content, Item, Separator |
| Drawer | @/ui/drawer | Side drawer overlay |
| Popover | @/ui/popover | Popover overlay |
| ContextMenu | @/ui/context-menu | Right-click menu |
| Skeleton | @/ui/skeleton | Loading placeholder |
| LoadingDots | @/ui/loading-dots | Animated loader |
| CopyButton | @/ui/copy-button | One-click copy |
| EmptyState | @/ui/empty-state | Empty state placeholder |
| Card | @/ui/card | Card container |
| Pagination | @/ui/pagination | Page navigation |
| Breadcrumb | @/ui/breadcrumb | Breadcrumb trail |
| Link | @/ui/link | Styled link |
| InternalLink | @/ui/internal-link | App navigation link |
| Calendar | @/ui/calendar | Date picker |
| Collapsible | @/ui/collapsible | Expandable section |
| ScrollArea | @/ui/scroll-area | Custom scrollbar |
| BulkActions | @/ui/bulk-actions | Compound: Root, BottomBar, CheckBoxItem, SelectAll |
| ToggleGroup | @/ui/toggle-group | Radio/multi-select group |
| SensitiveField | @/ui/sensitive-field | Masked sensitive data |
Icons
100+ icons in @/ui/icons/icon-{name}.tsx. Common: IconClose, IconCheck, IconCheckmark, IconSearch, IconChevronDown/Up/Left/Right, IconPlus, IconMinus, IconTrash, IconEdit, IconInformation, IconWarning, IconConfetti, IconCopy, IconExternalLink, IconSettings.
Live examples at src/app/(internal)/design/components/{name}/page.tsx.
Design Tokens
Defined in src/styles/globals.css (primitives) and src/styles/tokens.css (semantic layer) using Tailwind CSS v4 with CSS custom properties.
Color System
Built in two layers:
- Semantic tokens (the primary path) — intent-named utilities like
bg-elevated,text-default,bg-error. Components should reach for these first. - Primitives — raw Radix-based color steps (
gray-1..gray-12,red-3, etc.). Available as escape hatches when no semantic token fits.
Semantic tokens
Surfaces
| Token | Purpose |
|---|---|
bg-background | Page background. #fdfdfd light / #000 dark. |
bg-canvas | Canvas grid pattern surfaces. |
bg-subtle | Quietest page surface tier (gray-1). |
bg-elevated | Cards, sections, surfaces raised above the page (gray-2). |
Text
| Token | Purpose |
|---|---|
text-emphasis | Headings, prominent labels, input value (gray-12). |
text-default | Body text, button labels, form labels, helper text (gray-11). |
text-muted | Captions, metadata, disabled (gray-8). |
text-placeholder | Input placeholders (theme-asymmetric: gray-5 light / gray-8 dark). |
text-on-brand | Text inside the primary button (white light / black dark). |
Borders
| Token | Purpose |
|---|---|
border-default | Default static border — cards, sections, dividers (gray-3). |
border-subtle | Quieter static border (gray-2). |
border-interactive | Border on interactive components — Button (gray), Select, Input (gray-a3). |
Interactive (gray)
| Token | Purpose |
|---|---|
bg-interactive | Rest bg for Button (gray), Select, Input, Tag (gray-a2). |
bg-interactive-hover | Hover, focus, pressed, expanded, selected bg — all collapse to one value (gray-a3). |
ring-focus | Default focus ring (gray-a3). |
Brand (primary button)
| Token | Purpose |
|---|---|
bg-brand | Primary button rest bg. #000 light / #fff dark. |
bg-brand-hover | Primary button hover bg. black-a11 light / gray-12 dark. |
ring-brand | Primary button focus ring. black-a4 light / gray-a4 dark. |
Error (red, destructive)
| Token | Primitive | Purpose |
|---|---|---|
bg-error | red-a3 | Invalid input bg, destructive button rest, Tag |
bg-error-hover | red-a5 | Destructive button hover |
border-error | red-a6 | Invalid input, Toast error, form validation |
border-error-subtle | red-a4 | Quieter error border — Tag, Banner |
text-error | red-a11 | Error text, error icons, validation messages |
ring-error | red-a7 | Destructive button focus ring |
Warning (yellow → amber alpha)
| Token | Primitive | Purpose |
|---|---|---|
bg-warning | amber-a3 | Tag yellow, Toast warning before:bg |
border-warning | amber-a6 | Toast warning outline |
border-warning-subtle | amber-a4 | Quieter warning border — Tag |
text-warning | amber-a11 | Warning icons (TriangleAlert), banners, pending spinners |
Success (green)
| Token | Primitive | Purpose |
|---|---|---|
bg-success | green-a3 | Tag green, Toast success before:bg, audience subscribed |
border-success | green-a6 | Toast success, Switch checked state |
border-success-subtle | green-a4 | Quieter success border — Tag |
text-success | green-a11 | Success icons (CircleCheck), copy feedback, healthy states |
Info (blue)
| Token | Primitive | Purpose |
|---|---|---|
bg-info | blue-a3 | Tag blue |
border-info-subtle | blue-a4 | Tag blue border |
text-info | blue-a11 | Informational icons (IconOpenedEvent), status text |
Link
For clickable text and link affordances. Distinct from text-info (which is for informational icons / status).
| Token | Primitive | Purpose |
|---|---|---|
text-link | blue-a9 | Clickable text, links |
border-link | blue-a9 | Link hover underline (e.g. hover:border-link) |
ring-link | blue-a7 | Link focus ring (e.g. focus-visible:ring-link) |
When to use semantic vs primitive
Prefer semantic tokens whenever the use case matches one of the named roles above. They:
- Decouple intent from value — primitive can shift without touching components.
- Collapse multi-line
dark:variants into single tokens for theme-aware roles. - Survive scale changes (e.g. the gray renumber from 10 → 12 steps).
Fall through to primitives when:
- The intent isn't covered by a semantic token (e.g. softer text tiers
text-red-10/text-red-9for fade-style destructive buttons). - One-off decorative use (gradient stops, selection styling, niche backgrounds).
- Mid-scale grays (
gray-4throughgray-7) — these have no semantic name and remain available as escape hatches.
Primitives
Gray scale
| Range | Notes |
|---|---|
gray-1..gray-12 | Solid scale. 12 steps following Radix's convention. |
gray-a2, gray-a3, gray-a4 | Alpha steps. Only these three are kept — a1, a5–a12 are deprecated. |
light-gray-1..light-gray-12, light-gray-a1..light-gray-a10 | Static-light scale — does not flip with theme. Used by fade-gray Button variant and a few editor surfaces. |
Colored families (Radix alpha)
Available palettes — every step is alpha-backed via the @theme block, so e.g. bg-red-3 resolves to var(--red-a3):
gray, red, yellow (→ amber-alpha), green, blue, orange, violet, sand, cyan, mauve, black.
light-gray-* is the static-light gray scale.
Deprecated palettes
The following Tailwind default palettes are not in the system. Do not use them in any utility:
slate, zinc, neutral, stone, emerald, teal, sky, indigo, purple, fuchsia, pink, rose, amber (use yellow-* instead), lime.
Map by intent, preferring semantic tokens:
| Deprecated palette | Prefer semantic | Or primitive |
|---|---|---|
slate, zinc, neutral, stone | text-default, text-muted, bg-elevated, border-default | gray-* (or sand-* for warm neutrals) |
emerald, teal, lime | text-success, bg-success, border-success | green-* |
sky | text-info, text-link | blue-* (or cyan-* for specific accent) |
indigo, purple, fuchsia | — (no semantic family for violet currently) | violet-* |
pink, rose | text-error, bg-error, border-error | red-* |
amber | text-warning, bg-warning, border-warning | yellow-* (which maps to amber alpha) |
Radix step convention (primitive picks)
When reaching for a raw primitive, Radix's 12-step convention informs which step fits which intent. The semantic tokens above already follow this.
| Step | Intent |
|---|---|
| 1 | App background |
| 2 | Subtle background |
| 3 | UI element background (rest) |
| 4 | Hovered UI element background |
| 5 | Active / selected UI element background |
| 6 | Subtle borders and separators |
| 7 | UI element border and focus rings |
| 8 | Hovered UI element border |
| 9 | Solid backgrounds |
| 10 | Hovered solid backgrounds |
| 11 | Low-contrast text |
| 12 | High-contrast text |
Event lifecycle colors (not in semantic layer)
Email/automation lifecycle events have dedicated color mappings in src/schemas/{emails,broadcasts,automations,resend-webhooks}.ts, referenced via raw var(--<color>-aN). These are product-data, not UI semantics — var(--mauve-a11) for scheduled, var(--cyan-a11) for received, var(--violet-a11) for clicked, etc. UI components consume these via the schema; do not invent parallel tokens for these states.
Typography
| Token | Font | Usage |
|---|---|---|
font-sans | Inter | Body text, UI (default) |
font-display | ABC Favorit | Large headings (size 7-8) |
font-domaine | Domaine | Serif accents |
font-mono | Commit Mono | Code, monospace |
Use Heading and Text components with size prop rather than raw Tailwind text classes.
Sizing Scale
| Size | Height | Padding | Text | Radius |
|---|---|---|---|---|
'1' | h-6 (24px) | px-2 | text-xs | rounded-lg |
'2' | h-8 (32px) | px-3 | text-sm | rounded-xl |
'3' | h-10 (40px) | px-3 | text-sm | rounded-xl |
Border Radius
| Class | Value | Usage |
|---|---|---|
rounded-lg | 0.5rem | Size 1 components |
rounded-xl | 0.75rem | Size 2-3 components |
rounded-2xl | 1rem | Banners, larger elements |
rounded-3xl | 1.5rem | Cards |
rounded-4xl | 2rem | Dialogs |
Shadows
--shadow-3xl (large), --shadow-4xl (extra-large), --shadow-button (button-specific).
Animations
Scale & Fade: animate-open-scale-in-fade, animate-open-scale-up-fade, animate-close-scale-out-fade Slide & Fade: animate-open-slide-up-fade, animate-open-slide-down-fade, animate-close-slide-up-fade, animate-close-slide-down-fade Utility: animate-shine, animate-disco, animate-scroll-x, animate-caret-blink, animate-accordion-slide-down/up, animate-collapsible-slide-down/up, animate-fade-in, animate-fade-out
Custom Utilities
| Class | Effect |
|---|---|
fade-in-black | Horizontal fade mask |
bg-shine | Animated shine effect |
bg-gradient-fade | Gradient fade background (Banner) |
effect-font-styling | Display font styling for headings 7-8 |
Dark Mode
Via Tailwind dark: prefix. Background flips #fdfdfd → #000. Theme-aware semantic tokens (bg-brand, text-placeholder, etc.) handle their own theme switching internally. Gray scale adjusts via Radix-style remapping. Use dark: only for manual overrides when no semantic token covers the case.
Resend Heuristics
Guiding principles for UX decisions across the Resend dashboard. They help maintain a consistent mental model and resolve recurring "which pattern fits here?" questions.
Guidelines, not strict rules. Heuristics describe how Resend usually decides, not what every screen must do. Exceptions are expected. When a heuristic doesn't obviously apply, or a screen has a strong reason to break one, escalate to @design instead of forcing the rule.When to load these
Load the relevant heuristic file when:
- Choosing between two UI patterns (dialog vs stepper, hide vs disable, alert vs banner, etc.)
- Reviewing a screen and trying to explain why it feels off, beyond token/component violations
- Writing a
design-auditfinding that depends on judgment, not a grep rule — cite the heuristic asdesign_ref - A reviewer asks "what's the Resend convention for X?" and X is a decision, not a component
Index
| Heuristic | Decision it helps with |
|---|---|
| dialog-stepper-fullscreen-drawer | Which container pattern fits a task |
| disable-vs-hide | When to disable an action vs remove it |
| api-first | How API and dashboard capabilities relate |
| error-and-alert-communication | Inline error vs alert vs notification vs page error |
| table-required-fields | Which columns belong in a table's main view |
| button-appearance | Primary/secondary/destructive/ghost button choices, Button vs IconButton |
| complementary-information | Tooltip vs placeholder vs label vs drawer for helper content |
| affordance | Whether an element looks interactive when it shouldn't (or vice versa) |
| using-time | Relative vs absolute time, formats, thresholds |
| friendly-names-over-ids | When to show aliases instead of raw IDs |
| expose-debuggable-data | Surfacing raw payloads, error codes, sources |
| tag-colors-for-status | Reserving Tag / Badge colours for status, not decoration |
| keyboard-shortcuts | Adding shortcut hints and handlers to buttons, dialogs, and global surfaces |
Each row links to a file under heuristics/. Files land via separate PRs — links resolve as those merge.
How heuristics relate to other parts of the design system
- `design-system/references/components.md` answers how to build a primitive — props, sizes, slots.
- `design-system/references/patterns/` documents compositions of primitives that recur ≥ 3 times.
- Heuristics (here) answer which of several valid options to pick when more than one composition is reasonable.
A heuristic is not the same as a documented pattern. A pattern is a concrete composition you can copy-paste; a heuristic is the reasoning that tells you which pattern (or primitive) belongs on this screen.
Adding or updating a heuristic
1. Update the Notion source first (or get sign-off from @design). 2. Mirror the change into the matching file under heuristics/. Keep the file short — one screen of prose, plus a "Best practices" / "Anti-patterns" list where useful. 3. If the heuristic introduces a new judgment call the audit should flag, add a sentence in design-audit/references/rubric.md (Category 6) pointing to the file.
Affordance
Guideline, not a rule. Use this when reviewing whether a control looks the way it behaves.
Working definition
Affordances are the characteristics or properties of an object that suggest how it can be used. They show a user that an object can be interacted with.
>
An affordance is not a "property" of an object — it lives in the relation between the user and the object. A door affords opening if you can reach the handle. For a toddler who can't reach the handle, the same door doesn't afford opening.
A good affordance keeps the visual signal and the actual behaviour in sync. A broken affordance lies — it looks interactive when it isn't, or hides interactivity behind something that looks inert.
Principle
The user should be able to predict what happens before they click. If the UI suggests an action that won't work for this user, in this state, on this destination, the affordance is broken — fix the signal, don't add an apology afterwards.
Three quick checks
1. Does the control behave the way it looks? A button that's styled as enabled but does nothing on click is dishonest. Either re-style (disabled, with reason) or actually wire the action up. 2. Does the user know where the click will take them? External links should look distinguishable from internal links. Open-in-new-tab should be visible before the click, not a surprise after. 3. Is the visible state honest about availability? Loading, disabled, read-only, and invalid states must look different from the resting state — and from each other.
Common breakages
- A button labelled "Export" that's visibly active for non-admins, but actually no-ops or shows an error. → Disable it with a tooltip explaining the gate (see `disable-vs-hide`).
- A disabled button that still runs a spinner or hover animation. → A disabled control should be visually inert.
- An icon that looks clickable but is decorative, or a span of text that's clickable but looks like body copy. → Use the
Button/IconButton/Linkprimitives so affordance comes from the component. - External links styled identically to internal links. → Differentiate visually (e.g. trailing arrow icon, distinct hover) so the user predicts the tab switch.
- A "Save" button that stays the same on submit so the user clicks twice. → Switch to
state="loading"(see the design-system SKILL —statealready prevents interaction).
How to apply this in review
When auditing a screen, ask one question per interactive element:
| Question | If "no" |
|---|---|
| Does it look like what it does? | Re-style or re-wire. |
| Will the user predict where the click goes? | Add an icon, label, or hover cue. |
| Does the disabled state explain itself? | Add a tooltip or inline reason. |
| Does an animation only fire when the action is actually available? | Remove animation from disabled / read-only states. |
Related
- `disable-vs-hide` — honest disabling is the most common affordance fix.
- `button-appearance` — using the right appearance is part of giving an action the right affordance.
API-first features
Guideline, not a rule. Frames how the dashboard and the public API relate to each other.
Principle
APIs empower users to act independently and to integrate Resend into their own systems. Resend serves builders and makers who use the infrastructure from their preferred language. The dashboard exists to make the same building blocks visible, testable, and easier to operate — not as the canonical surface.
Overall guidelines
- The API is stable, predictable, and well-documented.
- The dashboard should mirror most API capabilities where it makes sense to expose them visually.
- The dashboard does not need to rely on the API internally — it can call lower-level services directly when that's simpler.
- The API does not need to support every dashboard capability — some workflows (e.g. visual editors) are dashboard-only by nature.
- The dashboard is a laboratory for testing new API capabilities before they ship publicly.
- Dashboard navigation should mimic the API schema as closely as possible, so users can map one to the other without re-learning.
How to apply this
When designing a new feature:
1. Sketch the API shape first — what resources, fields, and actions exist? 2. Then sketch the dashboard around that shape. Resource names, list/detail structure, and terminology should match what the API exposes. 3. Note which capabilities are dashboard-only (e.g. drag-and-drop email editor) and which are API-only (e.g. raw batch sends). Both are legitimate.
Anti-patterns
- Inventing a dashboard concept that has no API equivalent and no path to one (creates a "two products" feel).
- Naming things differently in the dashboard than in the API for the same underlying resource.
- Designing the API as an afterthought of the dashboard.
- Forcing every dashboard convenience to also exist in the API — see `predictable-by-design` for where parity actually matters.
Button appearance
Guideline, not a rule. Helps pick between Resend's button appearances, sizes, states, and composition options. Live reference: /design/components/button.The decision
Given a button you need to render, which appearance does it take, at which size, and how should it be composed (icon? shortcut?)?
Appearances — intent and hierarchy
Four roles. Pick the one that matches the action's importance on this screen.
| Appearance | Role | Notes |
|---|---|---|
white | Primary — the most important action on the page | Inverted black/white, flips in dark mode. One per screen. |
gray | Secondary — the obvious alternative path | Sits next to the primary. |
fade | Tertiary / subtle | Cancel, "Not now", dense action lists, low-stakes escape hatches. |
red / fade-red | Destructive | red for the destructive confirmation moment (Delete); fade-red for destructive actions in dense surfaces (row context menus, hover toolbars). |
Rules of thumb for hierarchy
1. One primary per screen. Two white buttons force the user to pick a winner the UI should have picked. 2. Pair the primary with a gray or fade. Confirm + cancel is white + gray, or white + fade. Never white + white. 3. Destructive primaries are still primaries. A red button counts as the screen's primary — don't also show a white next to it. The escape partner is fade or gray. 4. Use `fade-red` in dense surfaces. Row menus, list-item hover toolbars, and inline destructive actions look hostile in solid red. Save red for the confirmation step. 5. Appearance is a role, not a colour. "I want a green button" isn't a request the design system answers.
Sizes
Two sizes adapt to context.
| Size | Use when |
|---|---|
'1' | Compact. Dense interfaces — row toolbars, inline cells, tag-adjacent actions. |
'2' | Default. Forms, dialogs, page-level CTAs. Use this unless density forces '1'. |
Match the row's other elements (input height, tag size) before reaching for a different size.
A '3' exists in code for oversized hero / marketing-adjacent CTAs. It isn't part of the documented size set — treat it as the exception, not the default.States
Two states communicate interactivity. Both use the state prop, not separate booleans.
| State | Meaning | How |
|---|---|---|
loading | Action in progress | state="loading" — already prevents interaction; do not also set disabled. |
disabled | Action not available | state="disabled" — pair with a tooltip or inline note explaining why (see `disable-vs-hide`). |
Disabled buttons must be visually inert. No animated icons, no hover effects — see `affordance`.
Composition — icons and shortcuts
Buttons can carry an icon and/or a shortcut hint. There are conventions about where each goes.
Icons
- Left side → the icon names the action.
+ Add domain,↻ Retry,↓ Export. The icon reinforces the verb. - Right side → the icon names the destination.
Details ›,Settings ›,Open in new tab ↗. The icon hints where the click takes the user.
Never put an action icon on the right or a navigation icon on the left — it inverts the reader's expectation.
Shortcuts
- For power users. Surface a keyboard shortcut in a button when the action is frequent enough to deserve muscle memory.
- Limit to two modifiers.
⌘ ↵is fine;⌘ ⌥ ⇧ Kis not — reserve longer combos for a command palette or settings, not inline button affordances. - A shortcut hint doesn't replace a tooltip on an IconButton — see `complementary-information`.
Button vs IconButton
Pick IconButton when all of these hold:
- The action isn't used often enough on this screen for the icon meaning to feel learned.
- The icon is unambiguous (or a tooltip removes the ambiguity).
- Space is tight enough that a labelled button would crowd the layout.
Pick Button when the action is the obvious thing to do here, when the label adds clarity, or when the action is core enough to warrant a full row of attention.
Every IconButton needs an aria-label and a tooltip — without both, the screen-reader user and the hover-less user are guessing.
Anti-patterns
- Two
whitebuttons on the same screen, competing for the click. - A
rednext to awhite— the user can't tell which is "the primary". - Solid
redfor a reversible action like "move to trash" → usefade-red. state="loading"paired withdisabled={true}—statealready covers it.- An action icon on the right (
Export ↓) or a chevron on the left (› Details) — inverts the icon-placement convention. - A
Buttonwith three or more keyboard modifiers crammed into the hint. IconButtonwith noaria-labeland no tooltip.- Using
appearanceas a colour palette ("I need a green button"). Appearance is a role.
Related
- `affordance` — the appearance carries the affordance signal; disabled buttons must look inert.
- `disable-vs-hide` — when a button should be disabled rather than removed.
- `complementary-information` — what tooltip text to put on an
IconButton.
Complementary information (tooltip, placeholder, label, drawer)
Guideline, not a rule. Use this when a control needs extra explanation beyond its own label.
The decision
You have a control (input, button, switch, table column header, etc.) and you want to say something more about it — the format it expects, why it's disabled, what units it's in, what happens when you toggle it, where to learn more. Where does that extra information go?
The available surfaces
| Surface | Component | When the user sees it |
|---|---|---|
| Visible label | Plain text above the control | Always |
| Placeholder | placeholder= on TextField.Input | Only when the input is empty |
| Inline description | Text node beside / under a Switch, Checkbox, or section header | Always |
| TextField slot | <TextField.Slot> before / after the input | Always (icon or short adornment) |
| Tooltip | Tooltip primitive on hover/focus | Only when the user actively hovers / focuses |
| Field error | <TextField.Error> inside a trailing slot | Only after validation fails |
| Drawer / docs page | Drawer or a linked doc page | Only when the user explicitly opens it |
Principle
Pick the surface that matches how necessary and how long the information is.
- Necessary to use the control at all → it belongs in the visible label or an always-visible inline description.
- Helpful but only sometimes → tooltip or placeholder.
- Long enough to be a paragraph → linked drawer or doc page, not a tooltip.
Quick chooser
| The extra info is… | Put it… |
|---|---|
| The control's name | Visible label (never beneath the field) |
| A one-line example of the expected format | placeholder= |
| A short explanation of what toggling will do | Inline description next to the Switch / Checkbox |
| An icon hint (search, currency, prefix domain) | TextField.Slot |
| A definition the user only needs occasionally | Tooltip — keep it to one short sentence |
| Why the field is disabled or invalid | Tooltip on hover, plus TextField.Error on submit |
| A paragraph of context, a screenshot, a list of cases | Drawer or a link to documentation |
Specific rules
1. Labels go above the field, never beneath. A label under a TextField reads as helper text and gets lost when the field fills up. 2. Placeholder is not a label. It disappears the moment the user types. Use it for format hints (name@example.com, prod-...), not for the field's name. 3. Tooltips are short. If you need more than one short sentence, link to a doc instead of expanding the tooltip. Tooltips can't be selected, can't wrap nicely, and are invisible to touch users. 4. Booleans get inline descriptions, not tooltips. A Switch and a Checkbox should explain their effect inline, with one short line of helper text underneath. 5. Drawers are for long, complementary content — API references, change-log excerpts, schema docs. Reach for a drawer when the alternative would be a tooltip wall. 6. Icons inside inputs go in `TextField.Slot`, not as background images or absolutely positioned children. The slot system already adjusts input padding via ResizeObserver. 7. Validation errors use `TextField.Error`, not a free-floating <p> below the field. The primitive wires aria-describedby for you.
Anti-patterns
- A wall-of-text tooltip the user can't scroll, select, or pin open.
- A
Switchwith no inline description — the user has to flip it to find out what it does. - A
placeholderthat's the only naming of the field (no real label) — fails as soon as the user types. - A label below the input as "helper text."
- An icon adornment positioned with
absoluteinstead ofTextField.Slot, fighting the input's padding. - Tooltip used for a permanent piece of context the user always needs to read (move it to an inline description instead).
Related
- `button-appearance` — every IconButton needs a tooltip; this file says how long it should be.
- `affordance` — the right complementary surface keeps the control's affordance honest.
- `dialog-stepper-fullscreen-drawer` — drawers are also a container choice, not just a complementary-content surface.
Dialog, stepper, full-screen page, or drawer
Guideline, not a rule. Use this to pick a container for a task; escalate to @design when more than one option seems reasonable.The decision
When a user takes an action, where does it happen? In a dialog over the current page, in a multi-step flow, on a dedicated full-screen page, or in a drawer alongside the current page?
Three signals to weigh
1. Workload — does the task need guidance because of complexity or several decisions? 2. Occurrence — is it done many times, or rarely? 3. Context — where does the task begin, and where should it end?
The right container minimises the cognitive cost of those three for the user.
Dialog
For quick actions that may be done more than once. Ideal when the result is immediately visible on the previous page after closing.
- Example: editing a segment name. Fast, low decision count, no extra guidance, result visible behind.
- Reuses the existing context — the user doesn't lose where they were.
Best practices
- Avoid a dialog inside another dialog — it creates complex stacking and accessibility issues.
- A dialog can close via the
xbutton, clicking the overlay, or the cancel button. If the user has entered data in an input field, restrict closing to the `x` button to prevent accidental data loss.
Stepper
For complex but sporadic tasks that need guidance or multiple decisions. Ideal when the task ends on a different page than it began.
- Example: adding a domain. The user makes a few key decisions; after completion they land on a page that still needs attention.
- Each step is a small win that signals progress on a heavy, one-off task.
Full-screen page
For tasks that need focus and freedom from surrounding distractions. Ideal for tasks with a temporary state that can be saved or discarded later, such as editors.
- Example: editing the unsubscribe page. The user concentrates on a single task without the outer dashboard chrome.
Drawer
For long, complementary content that should not disrupt the current task.
- Example: opening an API reference next to the page the user is working on.
- The drawer adds context — it doesn't take over.
Quick chooser
| Task profile | Container |
|---|---|
| Fast, repeated, result visible on the same page | Dialog |
| Multi-decision, rare, lands on another page | Stepper |
| Single deep-focus task with save/discard state | Full-screen |
| Auxiliary reference content next to the page | Drawer |
Anti-patterns
- Dialog inside dialog.
- Dialogs that close on overlay click while the user has typed unsaved input.
- Stepper for a task that fits in one screen and is done frequently.
- Full-screen for a quick edit the user repeats often.
- Drawer used as a primary task surface instead of a complement to the page.
Disable instead of hide
Guideline, not a rule. Helps the user keep a stable mental map of where features live, even when they can't currently use them.
The decision
When the current user can't perform an action (wrong plan, wrong role, missing prerequisite), should the control be hidden or visibly disabled?
Principle
Disable when this user can change their own state to unlock the control — upgrade a plan, add the missing prerequisite, finish the step before. Hide when they can't, or when the control would leak data they shouldn't see.
A disabled control with no recovery path is just noise: a member who can't promote themselves to admin doesn't benefit from seeing a greyed-out admin button on every page.
When to disable
- Plan-gated features the user can unlock themselves (Pro-only buttons shown to free users — pair with an upsell or paywall).
- Conditional actions waiting on a prerequisite (e.g. "Send broadcast" disabled until at least one contact is added).
- Anything that is about the user's ability to act right now, with a recovery path they control.
Pair the disabled state with a tooltip, inline note, or upsell that explains the reason and points at the unlock. Don't leave the user guessing.
When to hide
- Role-gated actions the user has no way to grant themselves (admin-only export shown to members who can't become admins).
- Sensitive data that has no business reaching this user (billing line items, payment methods, audit log entries scoped to other roles).
- Features that aren't part of this product surface for this audience (admin-only sections inside a member's view).
- Anything where exposing the label itself leaks information.
Anti-patterns
- A disabled button with no tooltip or message — the user has no idea why they're blocked.
- A disabled control the user can never unlock themselves (admin-only action shown to a member) — hide it.
- Hiding an upsell-able feature so users never discover it exists.
- Hiding a button on one screen while keeping it visible on another for the same user — breaks the mental map.
- A disabled button that still animates (loading spinner, hover effects) — see `affordance`.
Error and alert communication
Guideline, not a rule. Helps choose how visibly an error should appear, based on how predictable it is and how much the user needs to deal with it now.
The decision
When something goes wrong, where does the message live? Attached to the offending field, as a global notification, or as a full page error?
Principle
Match the surface to the predictability and scope of the error. Predictable, local problems get local feedback; unpredictable, page-breaking problems get prominent surfaces. The user should never have to hunt for the explanation of a failure they just caused.
Field error
Use to prevent predictable mistakes from happening, or to point at the exact field that's wrong.
- Validation rules the form already knows (required, format, length).
- Rendered via
TextField.Error. The primitive shows an error icon in the field's slot when the field is invalid and reveals the message in a tooltip when the user hovers or focuses the icon — no inline text below the field. - Keeps the field's footprint stable (no layout shift when the error appears) and fixes the user's attention on the field that needs changing.
Global notification
Use for any alert that needs the user's immediate attention regardless of which page they're on.
- Account suspended, billing failed, service-wide incidents.
- Persists across navigation until acknowledged or resolved.
- Reserve for rare, high-priority signals — overuse trains users to dismiss them.
Page error
Use for uncaught runtime errors that block the entire page from rendering.
- The page can't recover on its own; the user must reload, navigate away, or be redirected.
- Should explain in plain language what happened and offer a next step.
Quick chooser
| Problem profile | Surface |
|---|---|
| Predictable, scoped to a field | Field error (TextField.Error) |
| Account- or product-wide, must not be missed | Global notification |
| Page cannot render at all | Page error |
Anti-patterns
- Toast notifications for validation errors that should have been a field error.
- A
<p>of error text rendered below aTextField— the field-error tooltip is the canonical surface; inline message text creates layout shift and bypasses the primitive. - Global notifications for routine, recoverable issues — desensitises the user.
- Page errors that say "Something went wrong" with no recovery path.
Expose debuggable data
Guideline, not a rule. Resend's users are developers — show them the raw signal whenever a UI summary loses information they'd otherwise need to dig out of logs.
Principle
When something fails, behaves unexpectedly, or has a state the user didn't directly cause, show the underlying data alongside the human-friendly summary. The dashboard should be a faster path to the same answer the user would get by curl-ing the API or reading their server logs — not a more polished one that loses fidelity.
What "debuggable data" usually means
For any event, status change, or error surfaced in the UI, expose at least:
- What happened — the human-readable label (
"Bounced","Delivery failed","Webhook attempt 3 of 5 failed"). - Why — the reason code or category the upstream system returned.
- When — relative time on the surface, exact UTC timestamp in the tooltip (see `using-time`).
- Where it came from — the source / provider / endpoint / IP, when the user could care.
- The raw record — the underlying JSON, headers, or payload, available without leaving the page.
Apply it on these surfaces
- Email events / debounce data — show the bounce reason in plain language, and expose the raw provider response next to it. The user trying to debug a deliverability issue needs both.
- Contact activity — every event (open, click, bounce, complaint, unsubscribe) should name its source (which broadcast, which webhook, which API call) so the user can trace the trigger.
- Webhooks — for each attempt, show the response status, the original error code, the number of retries, the next-attempt time, and the full request + response payload. A webhook page that hides the payload is failing the only person who'd look at it.
- API key / token usage — show the calling IP, user-agent, and timestamp on recent activity so a user can confirm a key isn't compromised.
- Background jobs — surface the queue state, attempt count, and last error message; let the user re-trigger.
How to surface it without cluttering the page
- Summary first, raw second. The row in a table is the summary; the detail view (or an inline collapsible) holds the raw payload.
- `<code>` blocks for raw payloads with copy-to-clipboard. Don't pretty-print them into a paragraph.
- Don't redact in the dashboard what the API would return to the same user. If the user could see it via
curl, hiding it in the UI just makes the UI less useful than the API — see `api-first`. - Detail views, not modals, for anything the user might want to keep open alongside another page.
Quick checklist
For a new surface that represents an event, ask:
1. Does the user see why it's in this state, in their own words? 2. Can the user see the raw payload without leaving the page? 3. Can they tell what triggered it (source, caller, broadcast, attempt #)? 4. If they wanted to file a support ticket, would they have everything in one screen to paste in?
If "no" to any of these, the surface is hiding signal.
Anti-patterns
- A status pill (
Failed) with no reason and no link to the payload. - "Something went wrong. Contact support." with no error code, request ID, or timestamp.
- A webhook log that shows pass/fail but not the response body.
- A pretty-printed summary that doesn't expose the original JSON anywhere.
- Redacting fields in the dashboard that the API returns unredacted to the same key.
Related
- `table-required-fields` — debug fields live on the detail page, not in the main table.
- `api-first` — the dashboard shouldn't be a worse view of the data than the API.
- `using-time` — every event needs a precise timestamp behind the relative label.
Friendly names over IDs
Guideline, not a rule. Show humans the name they gave a thing; show the ID when they need to wire it up.
Principle
A friendly name (alias, label, title) tells the user which template / domain / contact this is. An ID tells the user how to reference it from code. Both are useful, but they answer different questions. The dashboard should put the friendly name first; the ID is a secondary detail the user reaches for when they're integrating.
A list of opaque re_a7B... rows forces the user to mentally translate every line. A list of names with the ID one click away keeps the page scannable and still copy-pasteable.
Quick chooser
| Resource | Primary identifier in the UI | Where the ID goes |
|---|---|---|
| API key | User-given name ("Production backend") | A copy-to-clipboard row in the detail view |
| Domain | The domain itself (mail.acme.com) | Not surfaced — the domain is the user-facing identifier |
| Template / broadcast | User-given title | Detail page, near the "Use in API" snippet |
| Contact | Friendly name if set, otherwise email | Detail page |
| Audience / segment | User-given name | Detail page |
| Webhook event | None — the event itself is ephemeral | The ID is the row |
| Background job / single email send | The recipient + subject summary | Always available for log correlation |
When the friendly name is required
- The resource is long-lived and the user will refer to it again (API keys, domains, templates, audiences).
- The resource appears in lists the user scans regularly.
- The user might link to it externally (Slack threads, support tickets).
- The resource is created by the user — they expect to name it.
For these, force a name at creation time, or generate a sensible default the user can rename later.
When the ID is fine on its own
- The resource is ephemeral (a single webhook delivery attempt, a single email send, a single job run).
- The resource is system-generated and the user never created it.
- The resource only appears in a debug context where the user is already operating in IDs (e.g. tracing a request via its ID).
How to render both together
- Primary: friendly name, in
text-defaultweight. - Secondary: monospace ID in
text-mutedwith a copy-to-clipboard affordance. Truncate with an ellipsis if the ID is long; the full value is available in the copied content. - In a table, the column header is the friendly name. The ID lives in the detail view, not as a second column.
Fallback when no friendly name exists yet
- For user-creatable resources without a name, show a sensible default (
"Untitled broadcast","Unnamed key") plus a CTA to rename. - Never show the raw ID as the "name" of a renamable resource — it teaches the user that IDs are the primary identifier, which is the opposite of this heuristic.
Anti-patterns
- A table of API keys whose first column is
re_a7B9.... Force a name at creation time. - Hiding the ID entirely so the user can't paste it into code.
- Showing an ID where the resource already has a more human identifier (the domain, the email address, the title).
- Inventing friendly names for ephemeral events that don't deserve one — webhook event IDs are fine.
Related
- `expose-debuggable-data` — the detail view, where the ID lives, is also where the raw payload lives.
- `table-required-fields` — the friendly name belongs in the main table; the ID does not.
Keyboard shortcuts
Guideline, not a rule. Use this when adding a shortcut to a dialog, form, or global surface — show it where the action lives, wire it once, and never let it become the only way through.
Principle
A shortcut is an accelerator layered on top of a control the user can already click. It speeds up the people who live in the dashboard; it never replaces the button. So two things have to be true at once: the action is reachable by mouse, and the keyboard path is visible on the control itself so a user can discover it without a cheat sheet.
Two halves make up every shortcut, and they live in different places:
- The label — a
Kbdhint rendered on or beside the control, using platform-aware glyphs (⌘on Mac,Ctrlon Windows/Linux). - The handler — the key actually wired up with
useHotkeys, gated by the same condition that gates the control.
Keep the two in sync. A hint with no handler is a lie (see `affordance`); a handler with no hint is a secret.
The building blocks
| Piece | Import | Use for |
|---|---|---|
Kbd | @/ui/kbd | Rendering a single key. Appearance auto-matches when used via Button; standalone defaults to gray. |
SHORTCUTS_VALUES | @/ui/kbd | Platform-aware key constants: CMD, CTRL, SHIFT, ALT, ENTER (↩), ESC (Esc), BACKSPACE (⌫). Never hardcode the glyph. |
Button's shortcut prop | @/ui/button | The canonical way to put a hint on an action. Pass a string for one key, [a, b] for a chord. The Kbd appearance is derived from the button's appearance automatically. |
useHotkeys | react-hotkeys-hook | Wiring the actual key handler. |
GlobalShortcuts | @/components/global-shortcuts | App-wide single-key and g+letter navigation shortcuts. |
Dialogs and forms — the canonical pattern
Every dialog with a primary action follows the same shape. Cmd/Ctrl+Enter submits, Esc cancels.
// Wire the submit shortcut. enableOnFormTags lets it fire while the user
// is typing in the input; the guard mirrors the button's own state.
useHotkeys(
'mod+enter',
() => {
if (!isPending && form.formState.isValid) onSubmit();
},
{ enableOnFormTags: ['input'] },
[isPending, form.formState.isValid, onSubmit],
);
// ...
<Button
type="submit"
state={isPending ? 'loading' : form.formState.isValid ? 'normal' : 'disabled'}
shortcut={[SHORTCUTS_VALUES.CMD, SHORTCUTS_VALUES.ENTER]}
>
Add
</Button>
<Dialog.Close asChild>
<Button appearance="gray" shortcut={SHORTCUTS_VALUES.ESC}>
Cancel
</Button>
</Dialog.Close>The rules that make this work:
1. `'mod+enter'`, not `'cmd+enter'`. react-hotkeys-hook's mod resolves to Cmd on Mac and Ctrl elsewhere — the same split SHORTCUTS_VALUES.CMD shows. The handler string and the displayed hint are two halves of one shortcut; keep them paired. 2. `enableOnFormTags: ['input']`. By default hotkeys are suppressed while focus is in a field. Submit chords must fire while typing, so opt the input tag back in. 3. Gate the handler exactly like the button. If the button is disabled/loading, the shortcut must no-op too (if (!isPending && form.formState.isValid)). The accelerator never bypasses the gate — including a typed-confirmation guard on a destructive dialog. 4. Don't re-wire Esc. The Dialog primitive already closes on Escape, and Dialog.Close already handles the click. shortcut={SHORTCUTS_VALUES.ESC} on Cancel is only a label. Adding a useHotkeys('esc', ...) duplicates behaviour the primitive owns. 5. Focus the primary input on open (autoFocus, or onOpenAutoFocus + a ref) so typing and Cmd+Enter work the instant the dialog appears.
Choosing the key
| Surface | Convention | Why |
|---|---|---|
| Action inside a form/dialog | Modifier chord — mod+enter to submit | Chords are safe to fire while the user is typing. |
| Dismiss a dialog | Esc (native) | Universal expectation; the primitive already does it. |
| Global navigation | g then a letter (g e → Emails, g d → Domains) | Sequences from CMDK_NAVIGATION_ACTIONS; won't collide with typing a single letter. |
| Global toggle | Bare single key (m theme, d docs) | Reserve these for app-wide actions, and only when no field/dialog is focused. |
Scoping and conflicts
Single-key global shortcuts are dangerous near text input — m should toggle the theme on a list page but type "m" inside a dialog. GlobalShortcuts guards every global hotkey with ignoreEventWhen, which checks for an open [role="dialog"] (and the help route). Any new single-key global shortcut must respect the same guard. Chords inside a dialog don't need it — they can't be typed by accident.
Displaying shortcuts
- In a button: use the
shortcutprop. Don't hand-place a<Kbd>next to the label — you'll lose the automatic appearance matching and the desktop-only behaviour. - In a navigation / command-palette row: render
<Kbd appearance="fade">per key, right-aligned against the label. - Always desktop-only. Button shortcut hints are
hidden md:flexby design — there's no keyboard on touch. The hint is a bonus; the tap target is the real affordance. So a feature must never be reachable only by a shortcut. - Always `SHORTCUTS_VALUES`, never a literal
⌘or"Ctrl", so Mac and Windows/Linux each see the right glyph.
Anti-patterns
- Hardcoding
⌘orCtrlinstead ofSHORTCUTS_VALUES.CMD. Windows users then see the wrong key. - Wiring
useHotkeys('esc', closeDialog)when theDialogalready closes on Escape. - A shortcut that fires while its button is disabled or loading — it must share the button's guard.
- A single-key global shortcut with no
ignoreEventWhenguard, so it triggers while the user types in a dialog. - Showing a
shortcuthint for a key that was never wired up — a broken affordance. - Hand-placing
<Kbd>beside aButtoninstead of using theshortcutprop. - An action that has a hotkey but no visible, clickable control.
Related
- `affordance` — a shortcut hint is an affordance; it must match a handler that actually exists and is currently available.
Tables should include only required fields
Guideline, not a rule. Keeps list views scannable and avoids visual clutter from columns that are mostly empty.
Principle
The main table view shows the columns that almost every row will have a value for. Optional fields with frequent gaps belong on the detail page, not the list.
How to apply this
1. List every column the table could show. 2. For each, ask: what fraction of rows will have a meaningful value here? If a large share will be empty, move it to the detail view. 3. Keep identifiers, status, and the field the user filters or sorts on most. Push descriptive or optional metadata to the row's detail page.
Examples
- A contacts table with
Emailpopulated everywhere andNamefilled in for a small minority → keepEmail, moveNameto the detail view (or merge it under the email). - A domains table where
Regionis always set butCustom return pathis rare →Regionstays,Custom return pathmoves.
Anti-patterns
- Adding columns because they exist on the resource, not because users need them at a glance.
- A table whose main column is half blank — it reads as data missing, not as "this field is optional".
- Burying the column the user actually filters by behind a long row of optional metadata.
Related
- `expose-debuggable-data` — the detail view is also where raw / debug fields belong.
- `friendly-names-over-ids` — when collapsing columns, prefer the friendly name over an ID.
Tag colors are for status, not decoration
Guideline, not a rule. ReserveTag(andBadge-style) colours for communicating state. Decorative colour breaks the visual grammar of the dashboard.
The decision
You're labelling something on the page — a row state, a category, a property. Which Tag appearance does it take, and is colour even the right signal?
Principle
The Resend dashboard treats colour as a semantic signal, not a palette to draw from. When a user sees a coloured Tag, they should be able to read it as the state of something — and the colour should already tell them how much attention it needs before they read the label.
Three visibility tiers map to three attention levels:
| Tier | Tag colours | What it means | Examples |
|---|---|---|---|
| Neutrals | gray, sand | Informational, low priority — the system is doing its job, no user attention required | Scheduled, Queued, Sent, Cancelled, Delivery delayed |
| Low–Med visibility | blue, violet, green | Informational, worth noticing — a meaningful event happened | Opened (blue), Clicked (violet), Delivered (green) |
| High visibility | red, yellow, orange | Needs action — something failed, requires a decision, or is at risk | Bounced (red), Complained (yellow) |
The user should learn this grammar once and have it work everywhere.
How to apply this
1. Is the thing you're labelling a status? If no — it's a tab name, a category, a label the user picked, a count — don't reach for a coloured Tag. Use a gray Tag (or no Tag at all). 2. If yes, which tier does the status sit in?
- The user doesn't need to do anything → neutral (
gray/sand). - Something happened the user might want to know → low–med (
blue/violet/green). - The user should look at this → high visibility (
red/yellow/orange).
3. Within a tier, the specific colour is a stable mapping per status type. Once Delivered is green, every place in the product that shows Delivered is green. Don't re-cast a status into a different colour on a different page.
When colour isn't the right signal
- Categories the user creates themselves (audience names, segment labels, custom topics) — use
gray. The user gives them meaning; the system shouldn't paint them. - Counts / metadata (
12 contacts,3 keys) — not a status. Usegrayif a Tag is needed at all, or plain text. - Filters and tabs — let the active state, not colour, communicate selection.
Anti-patterns
- A
greenTag on a custom audience name "to make it stand out." Green now stops meaning Delivered. - A
redTag on a high-priority broadcast that isn't actually in a failure state. Red is reserved for needs action. Bluefor "premium feature" or "new". That's decoration; use agrayTag, a label, or no Tag.- Two different colours on the dashboard for the same underlying status (e.g.
Deliveredshown asgreenon one page andblueon another). - Using
violet/orange/sandas decorative accents on cards or marketing-adjacent surfaces — they belong to the status grammar. - Reaching for
Tagwhere plain text or a single icon would do. Every Tag is a small claim on the user's attention; spending that attention on decoration trains the user to ignore them.
Related
- `affordance` — a status colour is itself an affordance; using it decoratively breaks the contract.
- `expose-debuggable-data` — the status colour is the summary; the raw reason and payload live behind the row.
Banneruses the same intent system at the section level (red= error,yellow= warning,green= success,blue= info) — seedesign-system/SKILL.md.
Using time
Guideline, not a rule. Pick the right time format for the surface — fast to read at a glance, precise on demand.
Principle
Two questions about time matter to a dashboard user:
1. How recently did this happen? — best answered by a relative label (2m ago). 2. Exactly when? — best answered by an absolute, timezone-explicit timestamp.
Show relative time on the surface; keep the absolute time one hover away. This way the page stays scannable, but the user can always recover the precise moment when they need it (for cross-referencing logs, support tickets, or other systems).
The rules
Relative time on the surface
- Use short units:
13s ago,4m ago,2h ago,5d ago,3w ago,2mo ago,1y ago. - Don't prefix with
about,less than,over, orapproximately. They add noise without information. - Singular vs plural is fine;
1m agois better than1 m ago. - Future times use the same units with
in:in 5m,in 2d. Same no-aboutrule.
Absolute time in the tooltip
Every relative-time element should be wrapped in a Tooltip whose content is the exact UTC timestamp:
- Format:
MMM d, yyyy, HH:mm:ss 'UTC'— e.g.Jan 29, 2026, 14:03:51 UTC. - Always UTC. Don't convert to the user's local timezone; the user expects UTC parity with the API.
- Include seconds — debugging often hinges on them.
When relative time stops being useful
Past ~30 days, relative time loses precision (2mo ago doesn't tell you which week). For surfaces that show older events, switch to absolute time as the primary label and drop the tooltip:
- Up to 30 days: relative on surface, absolute in tooltip.
- Older than 30 days: absolute on surface (
Nov 14, 2025), no tooltip needed — or a tooltip with the full time-of-day if seconds matter.
The exact threshold can be a project decision (24h, 7d, 30d); the principle is "switch to absolute before the relative form gets imprecise."
When to show duration instead of a timestamp
- For things that took time (a webhook attempt that retried for 12s, a job that ran for 3m), show the duration directly:
12s,3m 04s. Don't make the user subtract two timestamps. - For things that are about to happen, show "in N units" rather than the absolute future time, unless precision matters (a scheduled broadcast).
Anti-patterns
about 2 hours ago,less than a minute ago. →2h ago,<1m agoonly if "<1m" reads cleanly; otherwisejust now.- Showing relative time without an absolute tooltip — the user can't correlate to logs.
- Showing absolute time in the user's local timezone — diverges from the API and from other team members.
- Showing the raw ISO string (
2026-01-29T14:03:51.123Z) as the primary label. Reserve ISO for debug payloads and copy-to-clipboard. - "13 minutes ago" mixed with "2h ago" on the same surface — pick short or long form per surface and stick to it.
Related
- `expose-debuggable-data` — debug payloads should show full timestamps, not relative ones.
- `table-required-fields` — a relative time column is a strong candidate for the main view; a full timestamp belongs in the detail page.
Component Patterns
CVA (Class Variance Authority)
All variants use CVA for type-safe styling.
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn';
const myVariants = cva('base-classes', {
variants: {
appearance: {
gray: 'bg-interactive border-interactive text-default',
white: 'bg-brand text-on-brand',
},
size: {
'1': 'h-6 text-xs px-2 rounded-lg',
'2': 'h-8 text-sm px-3 rounded-xl',
},
},
defaultVariants: { appearance: 'gray', size: '2' },
});
interface MyProps extends React.ComponentProps<'div'>, VariantProps<typeof myVariants> {}
function MyComponent({ appearance, size, className, ...props }: MyProps) {
return <div className={cn(myVariants({ appearance, size, className }))} {...props} />;
}Conventions
- String literal sizes:
'1','2','3'— never numbers appearance= visual style,size= dimensions,state= interactive state- Always pass
classNamethrough CVA for merge support - Use
cn()from@/lib/cn(wrapstailwind-merge+clsx)
Compound Component Pattern
Object namespace (TextField, Avatar, BulkActions)
export const TextField = { Root, Slot, Input, Error };
<TextField.Root>
<TextField.Slot>...</TextField.Slot>
<TextField.Input size="2" />
</TextField.Root>Named exports (Select, Dialog, Tooltip, DropdownMenu)
import * as Select from '@/ui/select';
<Select.Root>
<Select.Trigger />
<Select.Content><Select.Item value="x">X</Select.Item></Select.Content>
</Select.Root>Object namespace for tightly coupled parts. Named exports for Radix-based primitives.
Slot Pattern (asChild)
Uses @radix-ui/react-slot to merge props onto a child element:
<Button asChild><Link href="/settings">Settings</Link></Button>
<Dialog.Trigger asChild><IconButton appearance="fade"><IconSettings /></IconButton></Dialog.Trigger>TextField Slot System
Auto-adjusts input padding via ResizeObserver:
<TextField.Root>
<TextField.Slot><IconSearch /></TextField.Slot>
<TextField.Input placeholder="Search..." />
<TextField.Slot><Button size="1" appearance="fade">Clear</Button></TextField.Slot>
</TextField.Root>Max one slot before Input, one after. Only TextField.Slot and TextField.Input as direct children.
State Management
Use state prop, not individual booleans:
<Button state="loading">Save</Button>
<TextField.Input state="disabled" />
<TextField.Input state="read-only" />The state prop is self-sufficient — it handles visuals and interaction in one place. Never combine it with manual disabled, appearance overrides, or conditional className guards:
// ✅ Correct
<Button state={isLoading ? 'loading' : 'normal'}>Save</Button>
<IconButton state={isSubmitDisabled ? 'disabled' : 'normal'} aria-label="Submit">
<ArrowUpIcon />
</IconButton>
// ❌ Wrong — manual wiring duplicates what state already does
<Button
appearance={isLoading ? 'gray' : 'fade'}
disabled={isLoading}
className={cn(!isLoading && 'bg-accent!')}
>
Save
</Button>
// ❌ Wrong — asChild + <button disabled> to work around state
<IconButton asChild aria-label="Submit">
<button disabled={isSubmitDisabled || undefined}>
{isLoading ? <Loader2 /> : <ArrowUpIcon />}
</button>
</IconButton>Rule: use-state-prop (see design-audit rubric Category 2).
Shared Styling
src/ui/shared.ts exports constants for dropdown/floating bar consistency:
import { dropdown, floatingBottomBar } from '@/ui/shared';
<div className={cn(dropdown.content.appearance, dropdown.content.sizing)}>
<div className={cn(dropdown.item.sizing, dropdown.item.appearance.gray)}>Item</div>
</div>Server vs Client Components
Default to Server Components. 'use client' only at lowest interactive leaf.
Already client: TextField, Checkbox, Dialog, Drawer, Collapsible, Calendar, BulkActions. Server-safe: Button, Heading, Text, Tag, Banner, Card, EmptyState, Kbd.
Accessibility
- Always
aria-labelon IconButton - Dialog auto-manages focus trap + escape
- Banner uses
role="alert" TextField.Errorwiresaria-describedbyautomatically- Checkbox supports
'indeterminate'
Patterns
Documented UI patterns for the Resend dashboard. A pattern is a composition of src/ui/ primitives that solves a recurring layout or interaction problem and belongs in /design/patterns/.
What counts as a pattern
A pattern must:
- Combine ≥ 2
src/ui/primitives in a specific, repeatable structure - Appear in ≥ 3 dashboard files in substantially the same form
- Have a documented page under
src/app/(internal)/design/patterns/
A single component usage (even complex) is not a pattern — patterns are compositions.
Currently documented patterns
documented-patterns.json is the authoritative list. As of this writing it is empty ([]). This directory will grow as patterns are extracted from the dashboard and documented.
Pattern scaffolding
When the design-audit skill identifies pattern candidates, the design team reviews them and promotes candidates to documented patterns by:
1. Adding a page at src/app/(internal)/design/patterns/<name>/page.tsx 2. Adding the name to documented-patterns.json 3. Adding a markdown file here (<name>.md) describing the pattern
Relationship to the audit
The design-audit rubric (category 5) uses documented-patterns.json to decide whether a detected composition is already documented. If the name is NOT in documented-patterns.json, the composition is reported as a pattern candidate — not a violation.
Files in this directory serve as the prose reference when Claude is asked "what pattern applies to X?".
Marketing Page Components Reference
Page Structure (src/components/public-page.tsx)
import * as PublicPage from '@/components/public-page';
// Exports:
PublicPage.Root // <div className="bg-black isolate">
PublicPage.Header // sticky nav with logo + links
PublicPage.Hero // PublicPageContainer with pt-16
PublicPage.Container // PublicPageContainer (content width)
PublicPage.Footer // global site footer (do not duplicate)Public Primitives (src/website/)
PublicHeading (@/website/heading)
<PublicHeading
as="h1" | "h2" | "h3" | "h4" | "h5" | "h6" // default: h2
size="1" | "2" | "3" | "4" | "5" | "6" // default: 4
color="white" | "gradient" // default: white
className?
/>Sizes: 1=base, 2=xl, 3=2.25rem, 4=3–3.5rem, 5=4–4.8rem (Domaine), 6=4–6rem (Domaine)
PublicText (@/website/text)
<PublicText
as="span" | "p" | "strong" // default: span
size="1" | "2" | "3" | "4" | "5" // default: 2
color="white" | "gray" | "gradient" // default: gray
weight="normal" | "medium" | "semibold" | "bold" // default: normal
className?
/>PublicButton (@/website/button)
<PublicButton
appearance="white" | "black" | "black-fade" | "fade" | "red" // default: white
size="2" | "3" | "4" // default: 2
state="normal" | "disabled" | "loading"
asChild? // renders child element instead of <button>
iconLeft? // ReactNode
iconRight? // ReactNode
/>Shared Website Components (src/components/website/)
| Component | Import path | Description |
|---|---|---|
FeatureHeading | @/components/website/feature-heading | Section headings with eyebrow |
FeatureDetails | @/components/website/feature-details | Feature bullet list |
FeatureGrid | @/components/website/feature-grid | Grid of feature cards |
FeatureSteps | @/components/website/feature-steps | Numbered step list |
FeaturePill | @/components/website/feature-pill | Small pill/tag badge |
Quote | @/components/website/quote | Customer/testimonial quote |
Carousel | @/components/website/carousel | Horizontal carousel |
CodeSnippet | @/components/website/code-snippet | Syntax-highlighted code |
SubtleCta | @/components/website/subtle-cta | Low-key CTA section |
PublicBorderLight | @/components/website/public-borderlight | Animated border glow |
PublicFadeBorder | @/components/website/public-fadeborder | Faded border effect |
PublicLightsource | @/components/website/public-lightsource | Radial light effect |
BorderTrail | @/components/website/border-trail | Animated border trail |
Wrapper3dMouse | @/components/website/wrapper-3d-mouse | 3D mouse parallax |
BackgroundScrollOpacity | @/components/website/background-scroll-opacity | Scroll-driven opacity |
LlmExplainer | @/components/website/llm-explainer | LLM feature explainer |
CTAs (src/components/ctas/)
import { CallToAction } from '@/components/ctas/cta';
// Full-width CTA section — place at bottom of every page before FooterFeature Page Sections (src/components/product-pages/)
Organized by feature: email-api/, smtp/, webhooks/, inbound/, broadcasts/, audiences/, templates/, transactional/, marketing/
Each exports named components like <EmailApiHero />, <EmailApiCarousel />, etc.
Utilities
import { getBaseUrl } from '@/utils/base-url';
import { getCompanyJsonLd } from '@/utils/json-ld';
import { cn } from '@/lib/cn';DO NOT USE on marketing pages
src/ui/button→ usesrc/website/buttonsrc/ui/text→ usesrc/website/textsrc/ui/heading(if exists) → usesrc/website/heading- Dashboard layout components (
DashboardShell,Sidebar, etc.)
Resend Design Skills
An agent skill collection that provides Resend's brand guidelines and design system directly in your workflow.
Installation
npx skills add resend/design-skillsWhat's Included
resend-brand
Brand guidelines for marketing materials, social graphics, presentations, and external-facing visual content.
Colors
- Resend Black:
#000000/ Resend White:#FDFDFD - Brand tokens:
bg-brand,bg-brand-hover,text-on-brand,ring-brand(theme-aware: black light / white dark) - Semantic status colors: error (red), warning (yellow/amber), success (green), info (blue), link
- Each status has paired tokens:
bg-X,border-X,border-X-subtle,text-X(plus hover/ring variants where used)
Typography
- Domaine Display Narrow — Display headlines (never in product UI)
- Favorit — Headings & titles
- Inter — Body text
- CommitMono — Code
Logo Assets
- CDN links to official wordmarks and lettermarks (SVG/PNG)
- Usage restrictions and clearspace requirements
Design Elements
- Gradients (font, smooth, border, rainbow)
- Glass blur effect, noise texture
- Layout patterns (Right Object Scene, Interface Scene, Text Only variants, Big Number)
---
resend-design-system
Component APIs, design tokens, and composition patterns for building product UI inside the Resend codebase.
UI Components — 57+ primitives in src/ui/ built on Radix UI and styled with CVA:
- Actions — Button, IconButton, CopyButton
- Form — TextField (compound), Select, Checkbox, Switch, Calendar
- Display — Heading, Text, Tag, Banner, Avatar, Card, EmptyState
- Overlay — Dialog, Drawer, Popover, Tooltip, ContextMenu, DropdownMenu
- Navigation — Tabs, Pagination, Breadcrumb, Link, InternalLink
- Feedback — Toast, Skeleton, LoadingDots
- Icons — 100+ SVG icons in
src/ui/icons/
Design Tokens from src/styles/globals.css (primitives) and src/styles/tokens.css (semantic layer):
- Semantic tokens: surfaces (
bg-elevated,bg-subtle), text (text-default,text-emphasis,text-muted,text-placeholder), borders (border-default,border-subtle,border-interactive), interactive (bg-interactive,bg-interactive-hover,ring-focus), brand (bg-brand+ variants), status (error/warning/success/info), link (text-link,border-link,ring-link) - Primitives: gray scale 1–12 (solid +
a2/a3/a4alpha), Radix-based colored families (red, yellow→amber-alpha, green, blue, etc.) - Typography scale (Inter, ABC Favorit, Domaine, Commit Mono)
- Component sizing scale (1/2/3), border radius, shadows, 20+ animations
Token philosophy: prefer semantic names first; fall through to primitives only when no semantic token fits.
Component Patterns — CVA conventions, compound components, slot system, Server vs Client boundaries
Heuristics — Resend's UX decision guidelines (mirrored from the Notion source). Used when choosing between two valid patterns: dialog vs stepper vs full-screen vs drawer, disable vs hide, where errors should appear, what belongs in a table's main view, and so on. Framed as guidelines, not strict rules — exceptions are expected, and the @design team is the escalation path.
---
marketing-pages
Page structure, component reuse rules, and public primitives for creating and editing marketing pages in src/app/(website)/.
Page Structure — Required PublicPage.Root/Header/Container/Footer composition pattern
Public Primitives from src/website/ (never use src/ui/ on marketing pages):
PublicHeading— sizes 1–6, colors: white | gradientPublicText— sizes 1–5, colors: white | gray | gradientPublicButton— appearances: white | black | black-fade | fade | red
Shared Components — 16+ reusable sections in src/components/website/ (FeatureGrid, Carousel, CodeSnippet, Quote, etc.)
SEO — Required metadata export and JSON-LD structured data for every page
---
Usage
Once installed, Claude will automatically apply the right skill based on context:
- Ask for brand colors, typography specs, or logo assets →
resend-brand - Build UI components, forms, or pages in the Resend codebase →
resend-design-system - Create or edit marketing pages in
src/app/(website)/→marketing-pages
Example Prompts
What's the Resend color for error states?Build a settings form with email validation using Resend's TextFieldCreate a confirmation dialog with a destructive delete actionShould this be a dialog or a stepper? It's a 3-step domain setup flow.This export button is admin-only. Should I hide it for members or just disable it?I'm designing a Resend social graphic. What layout pattern should I use?Create a new marketing page for the webhooks featureLicense
MIT
Related skills
How it compares
Use resend-design-skills as the router when you need official Resend tokens and components instead of generic frontend-design guidance.
FAQ
When should I use resend-brand?
For marketing pages, social graphics, presentations, and external-facing visual content.
When should I use design-audit?
On audit design requests, design alignment checks, or the scheduled Monday dashboard routine.
Where do marketing pages live?
Under src/app/(website)/ per the marketing-pages skill routing.
Is Resend Design Skills safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.