Private Social Network

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.

Lý Thu Trang
280 left

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.

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

Nguyễn Đức Thành

Column two is the one I keep thinking about.

Vũ Thanh Hà

Putting the actions on the board this afternoon — @ducthanh, column two is yours.

Lê Văn Hùng

@hungleWeekend 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

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

Vũ Thanh Hà

@thanhhaDesign Circle

Space

Type specimen from the archive print — the one we keep arguing about.

Liked by Trần Thị Mai and 1 other

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.

Đỗ 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.

Nguyễn Đức Thành
Interests:
Product strategyCyclingBoard games
Trần Thị Mai
Interests:
Type designFilm photographyCeramics
Lê Văn Hùng
Interests:
HikingTrail runningRust
Phạm Minh Anh
Interests:
ClimbingPhotographyRust
Đỗ Quang Huy
Interests:
HikingCyclingBoard games
Vũ Thanh Hà
Interests:
Type designBakingPodcasts
Đặng Tuấn Kiệt
Interests:
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.

Private

Design Circle

Critique, specimens and the long-running serif argument.

+1
4 members

Last post

Invite only

Weekend Hikers

Trips within three hours of the city. Photos welcome.

3 members

Last post

Open

Release Notes

What shipped, what broke, what is next.

+3
6 members

Last post

Open

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

Your photo: a mountain trail at sunrise
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

Lý Thu Trang

@thutrang · Community lead

Interests:
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.

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.