UI Component Library
packages/ui (@repo/ui) is the shared React component library for Closegram. It is the single source of truth for the look of buttons, form controls, avatars, badges, spinners and other primitives used by the main app (apps/frontend-nextjs) and the admin panel, so both consume the same components instead of re-declaring the same Tailwind strings per file.
The package is developed and previewed in isolation with Storybook (pnpm --filter @repo/ui dev, port 6006), which lets each component render without depending on any app. Components are grouped under the Primitives/ and Foundations/ titles in Storybook.
Importing
The package name is @repo/ui. Everything on the public surface is re-exported from src/index.ts:
import { Button, Badge, Avatar, Spinner, Tooltip } from '@repo/ui';
import { fieldClass, TextInput, Switch, Select } from '@repo/ui';
There are three entry points defined in package.json:
| Entry | Import | Contents |
|---|---|---|
| Main | @repo/ui | All components, class-string constants and the cx helper |
| Icons | @repo/ui/icons | The full icon set (generic + brand icons) |
| Styles | @repo/ui/styles.css | Compiled Tailwind stylesheet (import once at the app root) |
react and react-dom (^18.3.1) are peer dependencies — the consuming app provides them.
The cx helper
cx(...parts) is a tiny className joiner that drops falsy values, used internally by every component and re-exported for call sites:
import { cx } from '@repo/ui';
cx('rounded-lg', isActive && 'bg-cg-blue', className);
Components
Button
Canonical action button — a superset of the variants used across the main app and admin panel. Extends the native <button> attributes (via React.ButtonHTMLAttributes) and forwards a ref.
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'warning' | 'outline-danger' | 'outline-warning' | 'secondary' | Visual style |
size | 'sm' | 'md' | 'md' | |
icon | React.ReactNode | — | Optional leading icon rendered before children |
| ...rest | native <button> props | — | onClick, disabled, type (defaults to 'button'), etc. |
<Button variant="primary" size="md">Guardar</Button>
For the handful of settings/payouts pages that still declare the button style inline, the raw class strings are also exported: primaryButtonClass, secondaryButtonClass, dangerButtonClass, linkButtonClass.
IconButton
Round, ghost icon button that consolidates the repeated w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 … pattern used across headers, modals and toolbars. Extends native <button> attributes and forwards a ref.
| Prop | Type | Default |
|---|---|---|
size | 'sm' | 'md' | 'lg' (w-8/9/10) | 'md' |
| ...rest | native <button> props | — |
<IconButton size="md" aria-label="More"><MoreHorizontal /></IconButton>
Badge
Small pill/label. Superset used by the main app and the admin panel.
| Prop | Type | Default |
|---|---|---|
tone | 'neutral' | 'accent' | 'info' | 'success' | 'warning' | 'danger' | 'neutral' |
icon | React.ReactNode | — |
children | React.ReactNode | — (required) |
className | string | — |
<Badge tone="success">Verified</Badge>
Avatar
Circular avatar with a purple→pink gradient-initial fallback when no image is set.
| Prop | Type | Default | Notes |
|---|---|---|---|
src | string | null | — | Falls back to an initial when absent |
alt | string | '' | |
name | string | — | First character shown as the fallback initial |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | |
className | string | — |
<Avatar src={user.photo} name={user.name} size="lg" />
StoryRingAvatar
Instagram-style story ring around an avatar, coloured by the story's audience/visibility. visibility comes from User.activeStoryVisibility; null/undefined means no active story, which renders a plain avatar with no ring.
| Prop | Type | Default | Notes |
|---|---|---|---|
src | string | null | — | |
alt | string | '' | |
visibility | string | null | — | public, followers, close_friends, subscribers, private each get a distinct gradient |
sizeClass | string | — (required) | Tailwind size classes for the avatar itself, e.g. "w-10 h-10" |
fallbackSrc | string | '/default-avatar.png' | Image used when src is empty |
className | string | — |
<StoryRingAvatar src={user.photo} sizeClass="w-14 h-14" visibility="close_friends" />
The helper storyRingClass(visibility) is also exported and returns the ring gradient classes (or null when there's no active story).
Spinner
Inline loading spinner that inherits the current text color (border-current). Renders a role="status" element with aria-label="Loading".
| Prop | Type | Default |
|---|---|---|
className | string | 'w-4 h-4' |
<Spinner className="w-5 h-5" />
Skeleton
Base skeleton block — a pulsing gray placeholder. Compose width/height at the call site. If the className already contains a rounded-* utility, the default rounded-md is skipped so a caller's rounded-full wins.
| Prop | Type | Default |
|---|---|---|
className | string | '' |
<Skeleton className="h-3 w-32" />
Tooltip
Lightweight tooltip: wraps an element and shows a label on hover/focus. Renders nothing extra when label is empty.
| Prop | Type | Default |
|---|---|---|
label | string | — (required) |
side | 'top' | 'bottom' | 'left' | 'right' | 'top' |
children | React.ReactNode | — |
className | string | '' |
<Tooltip label="Copy link" side="bottom"><IconButton><Copy /></IconButton></Tooltip>
VerifiedBadge
Instagram/Twitter-style verified seal with a white checkmark. Also re-exported from @repo/ui/icons.
| Prop | Type | Default |
|---|---|---|
className | string | 'w-3.5 h-3.5 text-cg-blue flex-shrink-0' |
<span>{user.name}{user.verified && <VerifiedBadge />}</span>
AnimatedCount
Animated counter that pops and nudges up/down whenever value changes (using the Web Animations API). Skips the animation on first mount.
| Prop | Type | Default | Notes |
|---|---|---|---|
value | number | — (required) | |
format | boolean | true | Toggles compact K/M abbreviation |
className | string | — |
<AnimatedCount value={likeCount} />
The pure helper formatCount(n) (e.g. 1200 → "1.2K", 3_400_000 → "3.4M") is also exported.
RollingNumber
Rolls/counts from its previous value to the new one, showing every intermediate number via requestAnimationFrame. Skips the roll on first mount and starts each roll from whatever is currently on screen so rapid changes stay smooth.
| Prop | Type | Default |
|---|---|---|
value | number | — (required) |
className | string | — |
<RollingNumber value={followerCount} />
DateTimePicker
Custom date + time picker with no browser-native calendar and no external dependency — a month grid plus hour/minute selects. It returns the same local 'YYYY-MM-DDTHH:mm' string an <input type="datetime-local"> uses, so existing callers don't change. Marked 'use client'.
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | — (required) | 'YYYY-MM-DDTHH:mm' local string, or '' |
onChange | (value: string) => void | — (required) | |
min | Date | — | Earliest selectable datetime |
placeholder | string | 'Fecha y hora' | |
panel | boolean | false | Inline always-visible panel (grid scrolls, time row + "Done" pinned) vs. a trigger button + floating popover |
onDone | () => void | — | Called by the panel's "Done" button (e.g. to close an overlay) |
className | string | '' |
<DateTimePicker value={when} onChange={setWhen} min={new Date()} />
Form primitives
Form.tsx is the single source of truth for the look of inputs, textareas, selects, labels and switches. It exports both ready-made components and the raw class-string constants they are built from.
Class constants: fieldClass (full-width bordered field), labelClass, inlineInputClass, inlineSelectClass — import these instead of re-declaring the same strings.
Components:
| Component | Key props | Notes |
|---|---|---|
FieldLabel | htmlFor, className, children | <label> styled with labelClass |
TextInput | native <input> props (ref-forwarded) | Styled with fieldClass |
TextArea | native <textarea> props | fieldClass + resize-none |
SearchInput | value, onChange(value), placeholder, onClear, inputClassName (SearchInputProps) | Rounded search field with magnifier icon and an optional clear button (shown when onClear is set and there is text) |
Switch | checked, onChange(), disabled, ariaLabel | Toggle; role="switch", canonical checked/onChange API |
Select | value, onChange(value), options? (SelectOption[]), children?, variant ('field' | 'inline'), disabled | Pass either options or <option> children |
SelectOption is { value: string; label: string }.
<FieldLabel>Idioma</FieldLabel>
<Select
value={lang}
onChange={setLang}
options={[
{ value: 'es', label: 'Español' },
{ value: 'en', label: 'English' },
]}
/>
<SearchInput value={q} onChange={setQ} onClear={() => setQ('')} placeholder="Buscar" />
<Switch checked={on} onChange={() => setOn((v) => !v)} ariaLabel="toggle" />
Callout / box styles
styles/boxes.ts exports inline callout class strings reused across the settings and payouts forms, so the same strings aren't re-declared per file:
successBoxClass(green)errorBoxClass(red)neutralBoxClass(gray)warningBoxClass(amber)
<div className={successBoxClass}>Saved.</div>
Icons
Imported from @repo/ui/icons. There are two groups.
Generic icon set (icons/lucide.tsx)
An auto-generated custom SVG icon library that is a drop-in replacement for lucide-react — path data is derived from lucide (ISC licensed) and rendered by an in-house wrapper, so the app no longer depends on the lucide-react package. Each icon accepts IconProps (native SVG props plus):
| Prop | Type | Default |
|---|---|---|
size | number | string | 24 |
strokeWidth | number | string | 2 |
absoluteStrokeWidth | boolean | — |
color | string | 'currentColor' |
import { Bell, Heart, Search } from '@repo/ui/icons';
<Heart size={20} className="text-cg-red" />
The set includes (among others) Activity, Archive, ArrowLeft, Bell, Bookmark, Calendar, Check, CheckCircle2, ChevronDown/Left/Right/Up, Circle, Clapperboard, Clock, Coins, Compass, Copy, CreditCard, Crown, DollarSign, Download, ExternalLink, Eye, Facebook, FileText, Gift, Globe, Grid3x3, Hand, Hash, Heart, Image, Languages, Link, Link2, Loader2, Lock, LogOut, Mail, Maximize2, Megaphone, MapPin, MessageCircle, Mic, MicOff, Minus, Monitor, Moon, MoreHorizontal, Package, Pencil, Phone, Pin, Play, Plus, Radio, Receipt, Repeat2, RotateCcw, Search, Send, Settings, Share2, ShieldAlert/Check/Off, ShoppingBag, Smile, Square, SquarePen, Star, Store, Sun, Ticket, Trash2, TrendingUp, Trophy, Truck, Twitter, Upload, UploadCloud, UserPlus, UserSquare2, Users, Video, VideoOff, X, XCircle, plus a custom square-cornered RepostSquare.
Brand icons
Hand-authored icons matching the app's navigation and post-action design. Most take { className?: string; filled?: boolean } (a filled variant for active/selected states); a few are outline-only (className only):
| Icon | Variants |
|---|---|
HomeIcon | outline / filled |
SearchIcon | outline / filled |
ExploreIcon | outline / filled |
ClipIcon | outline / filled |
MessagesIcon | outline / filled |
NotificationIcon | outline / filled |
HeartIcon | outline / filled |
ProfileIcon | outline / filled |
CoinIcon | outline / filled |
LanguageIcon | outline / filled |
CommentIcon | outline only |
CreateIcon | outline only |
EditIcon | outline only |
RepostIcon | outline only |
ShareIcon | outline only |
VerifiedBadge is also re-exported from @repo/ui/icons.
import { HomeIcon } from '@repo/ui/icons';
<HomeIcon filled={isActive} className="w-6 h-6" />
Design tokens & Tailwind
The library ships its own tailwind.config.ts so it renders correctly in isolation (Storybook) without depending on any app. Dark mode uses the darkMode: 'class' strategy — every component is styled for both light and dark: variants. See Theming for how the theme class is toggled at runtime.
The palette is defined under the cg (Closegram) color namespace and mirrors the tokens used by apps/frontend-nextjs. Key tokens:
| Token | Value | Use |
|---|---|---|
cg-blue | #3797f0 | Primary brand blue (+ -dark, -hover, -light, -action #0095f6) |
cg-green | #58c322 | Success |
cg-red | #ed4956 | Danger |
cg-bg | #ffffff / dark #0c1014 | Page background |
cg-dark-* | primary #121212, secondary, elevated #262626, modal, border, hover, input, … | Dark-mode surfaces |
cg-text-* | primary, secondary #a8a8a8, tertiary, placeholder | Text colors |
cg-separator / cg-muted | #262626 / #555555 | Dividers, muted elements |
The config file notes a follow-up to lift this palette into a shared
@repo/tailwind-configso the apps and this package share a single source of truth instead of duplicating it.
Consuming apps that don't compile the package's Tailwind classes themselves can import the pre-built stylesheet once at the root:
import '@repo/ui/styles.css';