agentleFS
jaballerjaballer/react-claude-skills/react-onboarding-docs/SKILL.md

react-onboarding-docs

Skill0 starsChanged 5 months agoMITView on GitHub

React patterns for product onboarding flows and documentation sites — multi-step flows, progress indicators, empty states, contextual hints, code-block components, TOC sidebars, on-this-page nav, callouts, copy-buttons, search, syntax highlighting. Use when building onboarding UI, product tours, documentation pages, knowledge bases, or developer-facing reference sites. Triggers on "onboarding", "tour", "walkthrough", "empty state", "tooltip", "documentation", "docs site", "code block", "TOC", "table of contents", "callout".

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Search input has a real label (<label className="sr-only">)

    line 437

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

react-onboarding-docs

Patterns for the two Netlify-scope surfaces beyond marketing pages: onboarding flows (first-run experiences, product tours, empty states) and documentation (code-heavy reference pages, callouts, navigation). Both are about getting a user to "aha" fast — onboarding through guided action, docs through scannable answers.

The shared principle: progressive disclosure

Both onboarding and docs fail when they show everything at once. Show what's relevant now, hide the rest behind a click or scroll.

  • Onboarding: one step at a time, each with one clear next action
  • Docs: TOC + collapsible sections + "show more" code blocks, not 8000-word walls

Part 1: Onboarding patterns

Multi-step onboarding flow

type Step = {
  id: string;
  title: string;
  description?: string;
  render: () => ReactNode;
};

export function OnboardingFlow({ steps, onComplete }: { steps: Step[]; onComplete: () => void }) {
  const [index, setIndex] = useState(0);
  const step = steps[index];
  const isLast = index === steps.length - 1;

  return (
    <section aria-labelledby="onboarding-title">
      <ProgressBar current={index + 1} total={steps.length} />
      <header className="mt-6">
        <p className="text-sm font-medium text-fg-muted">
          Step {index + 1} of {steps.length}
        </p>
        <h2 id="onboarding-title" className="mt-1 text-2xl font-bold">
          {step.title}
        </h2>
        {step.description && (
          <p className="mt-2 text-fg-muted">{step.description}</p>
        )}
      </header>
      <div className="mt-8">{step.render()}</div>
      <footer className="mt-8 flex items-center justify-between">
        <button
          type="button"
          onClick={() => setIndex(i => Math.max(0, i - 1))}
          disabled={index === 0}
          className="text-fg-muted disabled:opacity-50"
        >
          Back
        </button>
        <button
          type="button"
          onClick={() => (isLast ? onComplete() : setIndex(i => i + 1))}
          className="inline-flex items-center justify-center h-10 px-5 rounded-md bg-brand text-white font-medium hover:bg-brand-hover"
        >
          {isLast ? 'Finish' : 'Continue'}
        </button>
      </footer>
    </section>
  );
}

Progress bar (a11y-aware)

export function ProgressBar({ current, total }: { current: number; total: number }) {
  const percent = Math.round((current / total) * 100);
  return (
    <div
      role="progressbar"
      aria-valuenow={current}
      aria-valuemin={1}
      aria-valuemax={total}
      aria-label={`Step ${current} of ${total}`}
      className="h-1.5 w-full rounded-full bg-fg/10 overflow-hidden"
    >
      <div
        className="h-full bg-brand transition-all duration-300 ease-out"
        style={{ width: `${percent}%` }}
      />
    </div>
  );
}

Senior moves:

  • role="progressbar" + aria-valuenow/min/max so screen readers announce progress
  • aria-label describes the meaning, not the visual
  • Animate width is fine here — it's a one-shot transition, not a per-frame animation, and it's a single element

Empty state (the "we haven't shown you the product yet" moment)

type EmptyStateProps = {
  illustration?: ReactNode;
  title: string;
  description: string;
  primaryAction?: { label: string; onClick: () => void };
  secondaryAction?: { label: string; href: string };
};

export function EmptyState({
  illustration,
  title,
  description,
  primaryAction,
  secondaryAction,
}: EmptyStateProps) {
  return (
    <section className="flex flex-col items-center text-center py-16 px-6">
      {illustration && (
        <div aria-hidden="true" className="mb-6 text-fg-muted">
          {illustration}
        </div>
      )}
      <h3 className="text-xl font-semibold">{title}</h3>
      <p className="mt-2 max-w-md text-fg-muted">{description}</p>
      <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
        {primaryAction && (
          <button
            type="button"
            onClick={primaryAction.onClick}
            className="inline-flex h-10 px-5 rounded-md bg-brand text-white font-medium hover:bg-brand-hover"
          >
            {primaryAction.label}
          </button>
        )}
        {secondaryAction && (
          <a
            href={secondaryAction.href}
            className="text-brand hover:underline underline-offset-4"
          >
            {secondaryAction.label}
          </a>
        )}
      </div>
    </section>
  );
}

Senior frame for empty states: an empty state is not an error state. It's the highest-value real estate in your product — it's the user's first instruction. Treat it like a hero, not a "no data" placeholder.

Bad: "No projects yet." Good: "Connect your first site in under a minute. We'll guide you through linking a Git repo and your first deploy."

Contextual hint / tooltip (built on <button> semantics)

export function HintTooltip({ children, label }: { children: ReactNode; label: string }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  return (
    <span className="relative inline-block">
      <button
        type="button"
        aria-describedby={open ? id : undefined}
        onMouseEnter={() => setOpen(true)}
        onMouseLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
        onKeyDown={e => e.key === 'Escape' && setOpen(false)}
        className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-fg/10 text-fg-muted hover:bg-fg/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
      >
        <span aria-hidden="true">?</span>
        <span className="sr-only">{label}</span>
      </button>
      {open && (
        <span
          id={id}
          role="tooltip"
          className="absolute left-1/2 top-full mt-2 -translate-x-1/2 z-10 w-max max-w-xs rounded-md bg-fg text-bg text-sm px-3 py-2 shadow-lg"
        >
          {children}
        </span>
      )}
    </span>
  );
}

Senior notes:

  • Trigger is <button> — never just a <span> with mouseenter. Keyboard users need it too.
  • Opens on hover AND focus; closes on blur AND Escape
  • aria-describedby wires the tooltip content to the trigger
  • For production: use Radix UI Tooltip or React Aria — they handle delay, positioning, and edge cases (mobile, RTL)

"What's new" dismissible banner

export function ChangelogBanner({ id, title, href, onDismiss }: ChangelogBannerProps) {
  return (
    <aside
      role="status"
      aria-labelledby={`banner-${id}`}
      className="flex items-start gap-3 rounded-lg border border-border bg-bg p-4"
    >
      <SparkIcon aria-hidden="true" className="mt-0.5 h-5 w-5 text-brand flex-shrink-0" />
      <div className="flex-1 min-w-0">
        <p id={`banner-${id}`} className="font-medium">
          {title}
        </p>
        <a href={href} className="mt-1 inline-block text-sm text-brand hover:underline">
          See what's new →
        </a>
      </div>
      <button
        type="button"
        onClick={onDismiss}
        aria-label="Dismiss"
        className="text-fg-muted hover:text-fg p-1 -m-1"
      >
        <XIcon aria-hidden="true" className="h-4 w-4" />
      </button>
    </aside>
  );
}

Persist dismissal in localStorage keyed by the banner ID so it stays dismissed.

Part 2: Documentation site patterns

Code block with copy button (the bread-and-butter docs component)

'use client';
import { useState } from 'react';

type CodeBlockProps = {
  code: string;
  language?: string;
  filename?: string;
  highlightLines?: number[];
};

export function CodeBlock({ code, language, filename, highlightLines }: CodeBlockProps) {
  const [copied, setCopied] = useState(false);

  const copy = async () => {
    await navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <figure className="rounded-lg overflow-hidden border border-border bg-fg/[0.03]">
      {filename && (
        <figcaption className="flex items-center justify-between px-4 py-2 border-b border-border bg-fg/[0.04]">
          <code className="text-sm text-fg-muted">{filename}</code>
          {language && (
            <span className="text-xs uppercase tracking-wide text-fg-muted">
              {language}
            </span>
          )}
        </figcaption>
      )}
      <div className="relative">
        <pre
          tabIndex={0}
          aria-label={`Code example${filename ? ` from ${filename}` : ''}`}
          className="overflow-x-auto p-4 text-sm font-mono leading-relaxed"
        >
          <code className={language ? `language-${language}` : undefined}>
            {code}
          </code>
        </pre>
        <button
          type="button"
          onClick={copy}
          aria-label={copied ? 'Copied' : 'Copy code'}
          aria-live="polite"
          className="absolute right-2 top-2 inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-bg/80 backdrop-blur text-sm text-fg-muted hover:text-fg border border-border opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
        >
          {copied ? (
            <>
              <CheckIcon aria-hidden="true" className="h-3.5 w-3.5" />
              Copied
            </>
          ) : (
            <>
              <CopyIcon aria-hidden="true" className="h-3.5 w-3.5" />
              Copy
            </>
          )}
        </button>
      </div>
    </figure>
  );
}

Senior moves:

  • <figure> + <figcaption> — semantic structure for "labeled media"
  • <pre tabIndex={0}> so keyboard users can scroll long code blocks
  • Copy button announces state via aria-live (screen reader users know it worked)
  • Highlighting via Shiki or Prism — done at build time when possible (zero-runtime cost)

TOC (table of contents) — auto-generated from headings

'use client';
import { useEffect, useState } from 'react';

type Heading = { id: string; text: string; level: 2 | 3 };

export function TOC() {
  const [headings, setHeadings] = useState<Heading[]>([]);
  const [activeId, setActiveId] = useState<string>('');

  useEffect(() => {
    const els = Array.from(
      document.querySelectorAll<HTMLHeadingElement>('main h2, main h3')
    );
    setHeadings(
      els.map(el => ({
        id: el.id,
        text: el.textContent ?? '',
        level: (el.tagName === 'H2' ? 2 : 3) as 2 | 3,
      }))
    );

    const io = new IntersectionObserver(
      entries => {
        const visible = entries.filter(e => e.isIntersecting);
        if (visible.length > 0) setActiveId(visible[0].target.id);
      },
      { rootMargin: '0px 0px -70% 0px' }
    );
    els.forEach(el => io.observe(el));
    return () => io.disconnect();
  }, []);

  return (
    <nav aria-label="On this page" className="text-sm">
      <p className="font-semibold mb-3">On this page</p>
      <ul className="space-y-2">
        {headings.map(h => (
          <li key={h.id} className={h.level === 3 ? 'ml-4' : ''}>
            <a
              href={`#${h.id}`}
              aria-current={activeId === h.id ? 'location' : undefined}
              className={
                activeId === h.id
                  ? 'text-brand font-medium'
                  : 'text-fg-muted hover:text-fg'
              }
            >
              {h.text}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Senior detail: aria-current="location" is announced as "current location" by screen readers — far better than just visual styling.

Callout / admonition box

type CalloutKind = 'note' | 'tip' | 'warning' | 'danger';

const kindConfig: Record<CalloutKind, { icon: ReactNode; label: string; className: string }> = {
  note:    { icon: <InfoIcon />,     label: 'Note',    className: 'border-fg/20 bg-fg/[0.03]' },
  tip:     { icon: <SparkIcon />,    label: 'Tip',     className: 'border-brand/30 bg-brand/[0.05]' },
  warning: { icon: <WarningIcon />,  label: 'Warning', className: 'border-amber-500/30 bg-amber-500/[0.05]' },
  danger:  { icon: <DangerIcon />,   label: 'Danger',  className: 'border-red-500/30 bg-red-500/[0.05]' },
};

export function Callout({
  kind = 'note',
  title,
  children,
}: { kind?: CalloutKind; title?: string; children: ReactNode }) {
  const c = kindConfig[kind];
  return (
    <aside
      role="note"
      aria-label={title ?? c.label}
      className={cn('flex gap-3 rounded-lg border p-4 my-6', c.className)}
    >
      <span aria-hidden="true" className="flex-shrink-0 mt-0.5">
        {c.icon}
      </span>
      <div className="flex-1 min-w-0">
        {title && <p className="font-semibold">{title}</p>}
        <div className="prose-sm">{children}</div>
      </div>
    </aside>
  );
}

Senior detail: color is paired with an icon AND a label — never relies on color alone (a11y requirement).

On-this-page-quick anchor links (heading anchors)

export function AnchorHeading({ as: Tag = 'h2', id, children }: AnchorHeadingProps) {
  return (
    <Tag id={id} className="group scroll-mt-20">
      <a
        href={`#${id}`}
        aria-label={`Link to ${typeof children === 'string' ? children : 'section'}`}
        className="absolute -ml-6 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-fg-muted hover:text-brand"
      >
        #
      </a>
      {children}
    </Tag>
  );
}

scroll-mt-20 accounts for sticky header so anchored sections don't get hidden under it.

Docs search (UX shape, not implementation)

The pattern most senior:

  • Cmd+K opens a modal search overlay
  • Type-ahead with keyboard nav (arrow up/down, Enter to select, Esc to close)
  • Results grouped (Guides, API, Blog)
  • Empty state shows popular searches / recent

A11y must-haves:

  • Search input has a real label (<label className="sr-only">)
  • Results in role="listbox", items in role="option"
  • aria-activedescendant tracks the keyboard-highlighted item
  • Esc closes; focus returns to the trigger

This is one of the highest-effort components to build well. For an interview, mention you'd use Algolia DocSearch or Pagefind rather than rolling search yourself — it's the right tradeoff.

Documentation page skeleton (Next.js App Router shape)

// app/docs/[...slug]/page.tsx
export default function DocPage({ params }) {
  const { content, headings, frontmatter } = getDoc(params.slug);
  return (
    <div className="mx-auto max-w-7xl grid grid-cols-1 lg:grid-cols-[240px_1fr_240px] gap-12 px-6 py-12">
      <aside aria-label="Documentation navigation">
        <SidebarNav />
      </aside>
      <main id="main" tabIndex={-1}>
        <article className="prose prose-neutral max-w-none">
          <header>
            <h1>{frontmatter.title}</h1>
            {frontmatter.description && (
              <p className="lead">{frontmatter.description}</p>
            )}
          </header>
          {content}
        </article>
        <PrevNext />
      </main>
      <aside aria-label="On this page" className="hidden lg:block">
        <TOC headings={headings} />
      </aside>
    </div>
  );
}

Onboarding/docs UX principles (the things to mention)

  • One next action per screen — don't make users decide
  • Show the user where they are — progress for onboarding, breadcrumbs/TOC for docs
  • Optimistic state — assume success, show success, roll back on error
  • Don't trap users — always a Back, always a Skip (with consequences explained)
  • Skim-readable — bold the verbs, short paragraphs, generous whitespace
  • Code is content — copyable, scrollable, syntax-highlighted, accessible
  • Search is non-optional for docs — typing 4 characters > clicking 3 categories
  • The empty state is the hero — it's the first instruction the user sees

Common mistakes

MistakeFix
Modal onboarding with no SkipAlways allow skip; track who skipped to improve later
Multi-step form with no progress indicatorAlways show "Step N of M"
Tooltips on hover only (mouse-only)Open on hover AND focus, close on blur AND Esc
Code blocks with no copy buttonAdd a copy button; announce success
TOC without active section indicatorUse IntersectionObserver + aria-current
Callout that conveys urgency by color aloneAlways pair color with icon + text label
Docs page with no <main> landmarkWrap content in <main id="main"> + skip link

Narration phrases

  • "Empty states are the highest-value real estate in a product — this is the user's first instruction, so I framed it as a hero with one clear next action rather than a 'no data' placeholder."
  • "The progress bar uses role='progressbar' with aria-valuenow/min/max so screen reader users hear 'step 2 of 4' announced — visual progress alone leaves them behind."
  • "I used a real <figure> and <figcaption> for the code block — semantic 'labeled media' instead of a wrapper div."
  • "The TOC uses IntersectionObserver to mark the active section and aria-current='location' so screen readers announce it as 'current location'."
  • "For search, in a real build I'd reach for Algolia DocSearch or Pagefind — rolling search well is a multi-week project, not a multi-hour one."
  • "Callouts pair color, icon, and label — color alone fails a11y and the print-out version."

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.