Skip to content
ilinxa/pro-ui

Blackboard

alphav0.2.1

Chalkboard-style team notes board — handwritten ink notes with pens, widths, pins, @mentions, auto-save, and lazy history loading.

Category: Data DisplayUpdated: 2026-08-12Created: 2026-06-18Author: ilinxa

Context

A collaborative handwritten-note wall for dashboards and panels. Notes stream vertically (newest at the bottom) in the author's chosen ink color, chalk width, and handwriting font; the composer auto-saves (no Save button), scrolling up lazy-loads 10 older notes, hovering a note reveals its author inline, authors can @mention teammates and pin notes to a sticky row, and a handwritten red number marks unread notes. The board surface is themeable (color or custom image). Ships as a shadcn-style compound — headless BlackboardRoot + flat parts + standalone primitives + the Blackboard assembly — so a read-only wall falls out by dropping the composer. Portable by design: all persistence, real-time transport, and notifications are the consumer's via callbacks (no network I/O in the library).

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/blackboard

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/blackboard-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Double-click the board to reveal the composer (Esc or ✕ to dismiss). Pick an ink color, chalk width, and font; type @ to mention a teammate; scroll up to lazy-load older notes; the red number marks unread.

Pinned

Standup moved to 10:30 from tomorrow 👋

Maryam
Load older notes

Shipped the media-library smoke fix — green ✅

Ali

@Hessam can you review the blackboard plan?

@youSara

On it — pushing the GATE 2 plan now.

Hessam

Coffee machine on 3rd floor is fixed ☕

Devran

Design review @Maryam @Ali at 4pm in the slate room

Hessam
Double-click to write a note

Demo source

demo.tsxtsx
"use client"; import * as React from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { Blackboard } from "./blackboard";import { BlackboardRoot } from "./parts/blackboard-root";import { BlackboardSurface } from "./parts/blackboard-surface";import { BlackboardPinnedRow } from "./parts/blackboard-pinned-row";import { BlackboardNoteStream } from "./parts/blackboard-note-stream";import type { BlackboardNote, NoteDraft } from "./types";import {  BLACKBOARD_CURRENT_USER,  BLACKBOARD_MEMBERS,  BLACKBOARD_NOTES,  makeOlderNotes,} from "./dummy-data"; const OLDER_PAGES = 3; export default function BlackboardDemo() {  const [notes, setNotes] = React.useState<BlackboardNote[]>(BLACKBOARD_NOTES);  const [pinnedIds, setPinnedIds] = React.useState<string[]>(["n-101"]);  const [lastSeenNoteId, setLastSeenNoteId] = React.useState<string | null>("n-104");  const pageRef = React.useRef(0);  const [hasMoreOlder, setHasMoreOlder] = React.useState(true);   const onPostNote = React.useCallback(    (draft: NoteDraft) =>      new Promise<BlackboardNote>((resolve) => {        // Simulate a backend round-trip so the optimistic → reconcile path is exercised.        setTimeout(() => {          const real: BlackboardNote = {            id: crypto.randomUUID(),            text: draft.text,            author: BLACKBOARD_CURRENT_USER,            createdAt: new Date().toISOString(),            style: draft.style,            mentions: draft.mentions,          };          setNotes((prev) => [...prev, real]);          resolve(real);        }, 350);      }),    [],  );   const onLoadOlder = React.useCallback(async (_beforeId: string | null, limit: number) => {    await new Promise((r) => setTimeout(r, 500));    const page = pageRef.current;    pageRef.current += 1;    if (pageRef.current >= OLDER_PAGES) setHasMoreOlder(false);    const older = makeOlderNotes(page, limit);    setNotes((prev) => [...older, ...prev]);    return older;  }, []);   const onDeleteNote = React.useCallback((id: string) => {    setNotes((prev) => prev.filter((n) => n.id !== id));    setPinnedIds((prev) => prev.filter((p) => p !== id));  }, []);   return (    <Tabs defaultValue="full" className="w-full">      <SwipeTabsList>        <TabsTrigger value="full">Full board</TabsTrigger>        <TabsTrigger value="lighter">Lighter (read-only)</TabsTrigger>        <TabsTrigger value="custom">Custom background</TabsTrigger>      </SwipeTabsList>       <TabsContent value="full">        <p className="mb-2 text-sm text-muted-foreground">          <strong>Double-click the board</strong> to reveal the composer (Esc or ✕ to dismiss).          Pick an ink color, chalk width, and font; type <code>@</code> to mention a teammate; scroll          up to lazy-load older notes; the red number marks unread.        </p>        <div className="h-135">          <Blackboard            className="h-full"            notes={notes}            currentUser={BLACKBOARD_CURRENT_USER}            members={BLACKBOARD_MEMBERS}            canWrite            onPostNote={onPostNote}            onLoadOlder={onLoadOlder}            hasMoreOlder={hasMoreOlder}            pinnedNoteIds={pinnedIds}            onPinNote={(id) => setPinnedIds((p) => [...p, id])}            onUnpinNote={(id) => setPinnedIds((p) => p.filter((x) => x !== id))}            onDeleteNote={onDeleteNote}            onMention={() => {}}            lastSeenNoteId={lastSeenNoteId}            onSeen={setLastSeenNoteId}            editableBackground          />        </div>      </TabsContent>       <TabsContent value="lighter">        <p className="mb-2 text-sm text-muted-foreground">          Composed à la carte — just <code>BlackboardRoot</code> + <code>BlackboardSurface</code> +{" "}          <code>BlackboardNoteStream</code>. No composer, so none of the writing / mention bundle          ships. A kiosk or status wall pushes notes via the imperative handle.        </p>        <div className="h-110">          <BlackboardRoot            className="h-full"            notes={notes}            currentUser={BLACKBOARD_CURRENT_USER}            pinnedNoteIds={pinnedIds}          >            <BlackboardSurface>              <BlackboardPinnedRow />              <BlackboardNoteStream />            </BlackboardSurface>          </BlackboardRoot>        </div>      </TabsContent>       <TabsContent value="custom">        <p className="mb-2 text-sm text-muted-foreground">          The board surface is themeable — a solid color or a custom image (with a legibility          overlay). Click the palette (top-left) to switch, or paste an image URL. This board also          uses <code>composerMode=&quot;always&quot;</code> to keep the composer docked.        </p>        <div className="h-135">          <Blackboard            className="h-full"            notes={notes}            currentUser={BLACKBOARD_CURRENT_USER}            members={BLACKBOARD_MEMBERS}            canWrite            onPostNote={onPostNote}            onDeleteNote={onDeleteNote}            composerMode="always"            defaultBackground={{ kind: "color", value: "oklch(0.20 0.02 160)" }}            editableBackground            showNotificationBadge={false}          />        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for Blackboard when a team needs a shared, low-ceremony wall — a dark chalkboard you drop into a dashboard or panel where members jot short handwritten notes. Each writer picks an ink color, chalk width, and handwriting font; notes auto-save, the stream lazy-loads older notes on scroll-up, hovering reveals the author, and a handwritten red number marks unread notes. It is not a threaded comment list (comment-thread), a chat panel, or a freeform whiteboard.

Basic example

import { Blackboard } from "@/components/blackboard"

export function TeamBoard({ notes, me, team }) {
  return (
    <div className="h-[520px]">
      <Blackboard
        notes={notes}                 // controlled, oldest → newest
        currentUser={me}
        members={team}                 // drives the @-mention picker
        canWrite
        onPostNote={(draft) => api.post(draft)}      // auto-saves; no Save button
        onLoadOlder={(beforeId, n) => api.older(beforeId, n)}  // 10 on scroll-up
        hasMoreOlder
        onPinNote={(id) => api.pin(id)}
        onMention={(noteId, ids) => api.notify(noteId, ids)}
        lastSeenNoteId={me.lastSeen}
        onSeen={(id) => api.markSeen(id)}
        editableBackground
      />
    </div>
  )
}

Lighter (à la carte)

It ships as a compound. Compose the flat parts directly and drop what you don't need — omitting BlackboardComposer yields a read-only wall and never pulls the writing / mention code:

import {
  BlackboardRoot, BlackboardSurface, BlackboardNoteStream,
} from "@/components/blackboard"

<BlackboardRoot notes={notes} currentUser={me}>
  <BlackboardSurface>
    <BlackboardNoteStream />
  </BlackboardSurface>
</BlackboardRoot>

Notes

  • Portable by design.The library does no network I/O — persistence, real-time transport, and notifications are yours via callbacks. Push inbound notes with the imperative handle's appendNote().
  • Pin is controlled via pinnedNoteIds (or the uncontrolled note.pinned flag); pinned notes lift into a sticky row.
  • Fonts are bundled (Kalam / Caveat / Patrick Hand / Shadows Into Light) and overridable via the fontsprop. “Chalk width” uses real font-weight where the face supports it, a faux stroke otherwise.
  • The board is intentionally dark in both light and dark app themes (chalkboard identity). Type @ in the composer to mention a teammate.

Features

  • Vertical handwritten-note stream with per-note ink color, chalk width, and handwriting font
  • Auto-save (debounced, no Save button) + optimistic posting with reconcile/retry
  • Scroll-up lazy loading (10-note pages) with scroll-anchored, jump-free prepend
  • Inline author-on-hover label + handwritten red unread marker
  • @mentions over a team roster + pin-to-sticky-row, both capability-gated
  • Editable board background (solid color or custom image with legibility overlay)
  • Compound: headless Root + flat à-la-carte parts; drop the composer for a free read-only board

Tags

blackboardchalkboardcollaborationnoteshandwritingmentionsteamfeed

Dependencies

shadcn primitives: button, textarea
npm peer deps: @fontsource/kalam@^5.2.8, @fontsource-variable/caveat@^5.2.8, @fontsource/patrick-hand@^5.2.8, @fontsource/shadows-into-light@^5.2.8, date-fns@^4.1.0, lucide-react@^1.11.0