An invite-only social app, assembled from the package: a composer that names its audience and says who a mention will not reach, a filterable feed, a searchable member directory, private spaces, an activity inbox, a profile editor, and the privacy controls that keep a circle small and trusted.
A feed is lighter on composites than a table-shaped screen, and that is correct — there is no DataTable anywhere on this page. What the package does carry here is the identity layer: LetterAvatar and AvatarGroup for people, RelativeTime on every timestamp, FilterChips for the feed and inbox filters, StatusPill for who can see a post, and EmptyState for every view a filter can empty. Around it sit the parts that handle what people type and upload: Mention for @handles in a post, TagsInput for interests, BadgeOverflow to keep a row of them on one line, and Cropper for the photo. Each demo below is live — the filters filter, the follow and save buttons hold state, and the empty states are reachable.
Post composer
The audience selector is the whole screen. It is a Select backed by a StatusConfig table, mirrored into a StatusPill and into a sentence below the box, so the answer to “who can see this?” is in text before anyone presses Post. Type @ to mention a member: Mention suggests from the network, filtered with matchesSearch so @duc finds Đức. Mention someone the audience leaves out — Huy or Kiệt on a Circle-only post — and the line under the box says the post will not reach them: a mention never widens the audience. ⌘↵ posts, and KeyboardHint renders that as Ctrl off macOS.
LTLý Thu Trang
280 leftCtrl+↵to post
Circle onlyVisible to the 24 members you have approved. Not indexed, not resharable outside the circle.
Feed
FilterChips across the top with live counts; LetterAvatar, RelativeTime and a StatusPill audience chip on every post; AvatarGroup for who liked it. A mention stored with a post is tinted wherever its @handle appears, with the same tint the composer drew around it. Like, comment and save all hold real state — save a post and the Saved chip counts it, which is how you reach the EmptyState and come back out of it.
TMTrần Thị Mai
Trần Thị Mai
@maitran·
Circle
Retro wall from the offsite, photographed before someone wiped the board. Three themes, eleven actions, zero arguments.
Liked by Nguyễn Đức Thành and 2 others
NTNguyễn Đức Thành
Nguyễn Đức Thành
Column two is the one I keep thinking about.
VHVũ Thanh Hà
Vũ Thanh Hà
Putting the actions on the board this afternoon — @ducthanh, column two is yours.
LHLê Văn Hùng
Lê Văn Hùng
@hungle··Weekend Hikers
Space
Anyone up for Ba Vì on Saturday? Leaving at six, back before dark, and @quanghuy has room in the car. Keeping this to the space — I would rather the group stayed small.
Liked by Đỗ Quang Huy
NTNguyễn Đức Thành
Nguyễn Đức Thành
@ducthanh·
Circle
Shipped the new invite flow. Invites expire after seven days now, which should keep the member list honest.
+2
Liked by Trần Thị Mai and 4 others
VHVũ Thanh Hà
Vũ Thanh Hà
@thanhha··Design Circle
Space
Type specimen from the archive print — the one we keep arguing about.
Liked by Trần Thị Mai and 1 other
PAPhạm Minh Anh
Phạm Minh Anh
@minhanh·
Circle
What is everyone using to keep photo albums out of the public index? Asking before I upload the retreat set.
ĐHĐỗ Quang Huy
Đỗ Quang Huy
@quanghuy·
Public
We are opening five seats in the network next month. This one is public, so pass it on if you know someone worth inviting.
Liked by Nguyễn Đức Thành and 1 other
Member directory
SearchFilterBar over an ItemGroup, with matchesSearch doing the matching — it strips diacritics on both sides, so duc finds Đức. Interests sit under each name as a BadgeOverflow: one line of chips, the rest folded into “+N”, and the search reads all of them, folded ones included — try hiking. Mutual connections are an AvatarGroup rather than a number, and the follow button is a real toggle that the relationship filter reads back.
NTNguyễn Đức Thành
Nguyễn Đức Thành
@ducthanh · Product lead · joined
Interests:
Product strategyCyclingBoard gamesCoffee+99and 99 more
Product strategyCyclingBoard games
3 mutual
TMTrần Thị Mai
Trần Thị Mai
@maitran · Design · joined
Interests:
Type designFilm photographyCeramicsHikingCoffee+99and 99 more
Type designFilm photographyCeramics
2 mutual
LHLê Văn Hùng
Lê Văn Hùng
@hungle · Engineering · joined
Interests:
HikingTrail runningRustChess+99and 99 more
HikingTrail runningRust
+1
4 mutual
PAPhạm Minh Anh
Phạm Minh Anh
@minhanh · Engineering · joined
Interests:
ClimbingPhotographyRust+99and 99 more
ClimbingPhotographyRust
2 mutual
ĐHĐỗ Quang Huy
Đỗ Quang Huy
@quanghuy · Quality · joined
Interests:
HikingCyclingBoard games+99and 99 more
HikingCyclingBoard games
1 mutual
VHVũ Thanh Hà
Vũ Thanh Hà
@thanhha · Support · joined
Interests:
Type designBakingPodcastsYogaFilm photographyHiking+99and 99 more
Type designBakingPodcasts
+2
5 mutual
ĐKĐặng Tuấn Kiệt
Đặng Tuấn Kiệt
@tuankiet · Finance · joined
Interests:
RunningSpreadsheets+99and 99 more
RunningSpreadsheets
Private spaces
Topic spaces scope a conversation to a subset of the network. The square LetterAvatar is the cue that a space is a thing and not a person — the same shape rule the whole ecosystem uses for workspaces and apps — and its colour is derived from the name, so a space looks the same in every view. Privacy is a StatusPill from a status table, not a hand-picked badge colour.
DCDesign CirclePrivate
Design Circle
Critique, specimens and the long-running serif argument.
+1
4 members
Last post
WHWeekend HikersInvite only
Weekend Hikers
Trips within three hours of the city. Photos welcome.
3 members
Last post
RNRelease NotesOpen
Release Notes
What shipped, what broke, what is next.
+3
6 members
Last post
KTKitchen TableOpen
Kitchen Table
Recipes, lunch plans, and the coffee order spreadsheet.
2 members
Last post
Activity inbox
Rows are Item inside an ItemGroup, the same list machinery as the directory, with asChild making each row the button that marks it read. Each pairs a LetterAvatar with the icon for the event kind, taken from a static map — never a class name built at runtime, which Tailwind cannot see. Unread is a word plus a dot, not a tinted row; and Mark all read empties the Unread filter into an EmptyState.
Profile editor
The other half of the identity layer: what the directory, the feed and every AvatarGroup show for you. The photo goes through Cropper — drag, scroll, pinch, the arrow keys or + and - to frame it, and two buttons for anyone who wants a visible control — and Use this crop draws the crop onto a 256 × 256 canvas, the file an app would upload. Interests are free text, so they are a TagsInput: Enter or a comma adds one, a pasted list adds several, and the row underneath shows them the way the directory will. Try a photo of your own if you like — it never leaves the browser.
Profile
What members see next to your name — in the directory, on your posts, in their circles.
Photo
Use the arrow keys to move the image, hold Shift to move more slowly. Press + and - to zoom in and out.
150%
croppedAreaPixels → waiting for the image
Hiking
Film photography
Type design
Coffee
Pottery
Enter or a comma adds one; paste a list to add several. Up to 8.
Your row in the directory
LTLý Thu Trang
Lý Thu Trang
@thutrang · Community lead
Interests:
HikingFilm photographyType designCoffeePottery+99and 99 more
HikingFilm photographyType design
Two behaviours worth knowing. Cropper reports the area in two ways: onCropComplete when an interaction ends, and onCropAreaChange on every change, the first as soon as the image loads. Only the second covers the member who likes the first framing and never drags, which is why this page listens to it. And TagsInput's duplicate check is exact, so “hiking” would sit next to “Hiking”: onValidate makes it case-insensitive, and onInvalid says why a tag did not go in, in a live region that is on the page before it has anything to say.
Privacy controls
FormSection with a primary accent stripe, Switch rows that carry their own label and description, and two FormFields on the 12-column grid. The line underneath restates the effective settings in plain language, because a column of toggles does not tell anyone what their account currently looks like.
Privacy
Who can see you, and who can reach you. Defaults are the strict end of each choice.
Only members you approve can see your posts, your circle and your spaces.
Your profile stops appearing in the member directory. People who already follow you keep their link.
Members of your circle can reshare a post to their own circle. Never outside the network.
Other members can see when you were last active. Turning this off also hides read receipts in spaces.
Requests still need your approval — this only decides who may ask.
Capped by “Private account”: members outside your circle never see the list.
Right now: Only approved members see your posts. Your profile appears in member search. Reshares stay inside the network. Your activity status and read receipts are hidden.
Best practices
Do
State the audience in words before a member publishes, and repeat it on the post.
Tell the author when a mention will not reach someone — a mention never widens the audience.
Default new accounts to private; make wider visibility an explicit opt-in.
Drive audience and space privacy from one StatusConfig table so the chip means the same thing everywhere.
Seed every avatar from the display name — one person, one colour, in every view.
Upload the crop, not the original: draw croppedAreaPixels onto a canvas at the size the avatar is shown.
Give each filter an empty state with a way back out of it.
Let members scope conversations to spaces instead of one global feed.
Pair every privacy toggle with a plain-language description of its effect.
Confirm irreversible actions — leaving a space, deleting a post — with ConfirmDialog.
Don't
Do not make posts public by default in an invite-only network.
Do not express the audience with a padlock icon alone.
Do not rely on a tinted row to mark unread, or on colour alone for anything.
Do not expose member profiles to search without an opt-in.
Do not change language one component at a time with labels objects and locale props. The package is English by default; one ComitorLocaleProvider at the root switches all of it, and props stay for the words that belong to one screen.
Do not let a row of chips wrap onto a second line — fold the rest into “+N” with BadgeOverflow, and keep the folded ones searchable.
Do not build tone classes at runtime — text-{kind}-ink is purged from the bundle.
Do not bury unfollow or leave-space behind several taps.
Do not put more than one primary action on a card.
Accessibility
LetterAvatar already carries the member's name in an sr-only span on both the image and the initials branch. In a row that already prints the name — a post header, a directory row, a notification — the avatar is decoration, so give it aria-hidden; leave it announced only where nothing else names the person, as in the composer. The one place it must never be hidden is inside AvatarGroup, where that span is the accessible name of the avatar's own button.
Every AvatarGroup needs its own label: the English default, “Members”, reads the same on every row. Each avatar inside it is a focusable button so keyboard users reach the tooltip.
With Mention the post box is a role="combobox" wired to a named listbox — “Members” here, “Suggestions” by default. While the list is open ↑ and ↓ move, Enter picks a member and does not post, and Esc closes it; Backspace deletes a @handle whole, never a letter at a time. The warning about who a mention will not reach is a polite live region and part of the box's description.
Reaction, save, follow and join buttons are toggles: give them aria-pressed and one label that does not change with it — an sr-only verb where the button shows only an icon and a count, since those alone do not say whether the action is on. A label that flips to “Liked” or “Following” announces the state a second time.
The audience must be conveyed in text — the padlock is a reminder, not the message.
Unread needs a text or aria cue in addition to the dot; this page prints the word New.
BadgeOverflow hides “+2” from screen readers and reads “and 2 more” instead. The folded interests cannot be reached from the row, so the full list has to live somewhere else — the profile — and stay in search.
Each tag's ✕ in TagsInput is named “Remove Hiking” and kept out of the Tab order; from the text field, ← walks the tags, and Backspace selects the last one and removes it on a second press. Moving between tags is not announced — a known limit of the headless core, which does not use aria-activedescendant.
The crop area is a role="application" — named “Crop your profile photo” here — with its keys in its description; + and - zoom and say the new level through a live region. The zoom buttons are for anyone who does not know those keys.
RelativeTime renders a <time> with a real dateTime and an absolute timestamp in title, in the language of the provider at the root — English unless it says otherwise.
Switch renders its own <label htmlFor> when given label — take it rather than wrapping your own.
A composer textarea and a search field both need a label; SearchFilterBar uses its placeholder as the aria-label, so write it as a sentence.
Announce publish confirmations and follow changes through role="status".
FilterChips uses aria-pressed, not role="tab" — it toggles a condition rather than swapping a panel.