Blackboard
alphav0.2.1Chalkboard-style team notes board — handwritten ink notes with pens, widths, pins, @mentions, auto-save, and lazy history loading.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/blackboardAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/blackboard-fixturesCLI 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.
Standup moved to 10:30 from tomorrow 👋
Shipped the media-library smoke fix — green ✅
@Hessam can you review the blackboard plan?
On it — pushing the GATE 2 plan now.
Coffee machine on 3rd floor is fixed ☕
Design review @Maryam @Ali at 4pm in the slate room
Demo source
"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="always"</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 uncontrollednote.pinnedflag); 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