Loopd UI kit
The maintained Loopd design system, on one public page. Use it to check a component before you build a variant of it, to copy a token name, and to see how each piece behaves in dark mode. Resize to 390px: everything here is expected to hold.
Color
Tokens live in src/app/globals.css. Light uses :root; dark uses .dark. @theme inline exposes them to Tailwind. The two chips below each row are the light and the dark value; a faded second chip means .dark does not override that token. Light and dark use the same semantic roles across the component library.
How to read this
Left chip = :root · right chip = .dark
Click the copy icon to put var(--color-*) on your clipboard. Toggle the theme in the header to see every component below re-render.
Surfaces
14 tokens
The page ground is never white. Cards float on #f7f6fe in white with a 20–24px radius; muted fills stand in for borders on inputs and secondary pills.
--color-background
#f7f6fe → #08061e
bg-background Page ground behind every app and marketing screen.
--color-card
#ffffff → #100c30
bg-card Every card, panel, table shell and popover body.
--color-card-foreground
#030119 → #e8e6fe
text-card-foreground Ink on cards.
--color-popover
#ffffff → #100c30
bg-popover Notifications panel, avatar menu, select and tooltip surfaces.
--color-popover-foreground
#030119 → #e8e6fe
text-popover-foreground Ink inside popovers.
--color-muted
#f0effb → #171240
bg-muted Borderless input fills, add tiles, eyebrow pills, skeleton base.
--color-muted-foreground
#6b6488 → #8b82c0
text-muted-foreground Micro labels and helper text. Not placeholders: those have their own token.
--color-placeholder
#807a9c → #736b9e
placeholder:text-placeholder Placeholder text only, and 25% less contrast than muted-foreground so an empty field never reads as filled. Set once by Input/Textarea/SelectTrigger and the base ::placeholder rule: never respecify it per field.
--color-secondary
#eae8fe → #201a52
bg-secondary Gray pill buttons, active nav tab background, segmented chips.
--color-secondary-foreground
#030119 → #e8e6fe
text-secondary-foreground Ink on gray pills.
--color-accent
#eae8fe → #201a52
bg-accent Hover fill for ghost buttons, menu items and table rows.
--color-accent-foreground
#030119 → #e8e6fe
text-accent-foreground Ink on hover fills.
--color-surface-dark
#030119 → #241d68
bg-surface-dark Inset dark marketing sections (How it works, Active campaigns, For brands).
--color-surface-dark-foreground
#f7f7f8 → #f7f7f8
text-surface-dark-foreground Ink on the dark marketing sections.
Ink and lines
5 tokens
Two text colors carry almost the whole product: near-black for anything you read, #5c5578 for anything you skim.
--color-foreground
#030119 → #e8e6fe
text-foreground Headings, values, icons: the default ink.
--color-content-secondary
#5c5578 → #8b82c0
text-content-secondary Subtitles, meta rows ('2d ago • Per View'), body copy under a heading.
--color-border
#e2dff6 → #2c2668
border-border Hairlines: card outlines, table rows, section dividers.
--color-input
#e2dff6 → #2c2668
border-input Outlined control borders (select trigger, checkbox, OTP slots).
--color-ring
#1d09f6 → #6e62f9
ring-ring Focus ring. Text fields swap it for a 2px solid near-black border.
Actions and brand
6 tokens
Black pills are the product's primary action; brand-500 #1d09f6 is the brand; brand-600 #1807ca is reserved for links, switches and the lifecycle stepper. The app is otherwise monochrome.
--color-primary
#030119 → #e8e6fe
bg-primary Primary CTA pill (Continue, Join campaign, Submit post, Cash out).
--color-primary-foreground
#ffffff → #030119
text-primary-foreground Label on the primary pill.
--color-brand
#1d09f6 → #4535f8
bg-brand Loopd blue: the mark, hero CTAs, FAQ toggles, footer gradient, cookie banner.
--color-brand-foreground
#ffffff → #ffffff
text-brand-foreground Label on brand-blue CTAs.
--color-action
#1807ca → #978efb
bg-action Switch ON, campaign lifecycle stepper, inline links ('Let us know.').
--color-action-foreground
#ffffff → #030119
text-action-foreground Label on action-blue fills.
Semantic
7 tokens
Status colors appear as soft tinted pills far more often than as solid fills: 'Earning $2.00 per 1,000 views' and 'Added to wallet' are green-on-green.
--color-success
#087d3e → #0cb85b
text-success Approved submissions, 'Added to wallet', earnings badges.
--color-success-foreground
#ffffff → #030119
text-success-foreground Ink on solid green.
--color-warning
#f99c00 → #fcbb00
text-warning Pending / in-review states, '25% less earnings' connect badge.
--color-warning-foreground
#161616 → #030119
text-warning-foreground Ink on solid amber.
--color-destructive
#cf2533 → #ff6568
text-destructive Rejected clips, field errors, Close account, Delete.
--color-destructive-foreground
#ffffff → #ffffff
text-destructive-foreground Ink on solid red.
--color-info
#1807ca → #978efb
text-info Informational banners and info-circle tooltips.
Charts
5 tokens
Used by the brand dashboard only. No dark overrides: these five read on both grounds.
--color-chart-1
#5dc16b · inherits
bg-chart-1 Series 1: views.
--color-chart-2
#1d09f6 · inherits
bg-chart-2 Series 2: spend.
--color-chart-3
#f99c00 · inherits
bg-chart-3 Series 3: pending.
--color-chart-4
#ac4bff · inherits
bg-chart-4 Series 4: posts.
--color-chart-5
#ff2357 · inherits
bg-chart-5 Series 5: rejected.
Sidebar
8 tokens
Inherited from shadcn. Loopd has no sidebar; these are kept so shadcn blocks drop in without a token rewrite (the settings anchor nav uses them).
--color-sidebar
#ffffff → #100c30
bg-sidebar Sidebar surface.
--color-sidebar-foreground
#030119 → #e8e6fe
text-sidebar-foreground Sidebar ink.
--color-sidebar-primary
#030119 → #e8e6fe
bg-sidebar-primary Selected sidebar item.
--color-sidebar-primary-foreground
#ffffff → #030119
text-sidebar-primary-foreground Ink on the selected item.
--color-sidebar-accent
#f0effb → #201a52
bg-sidebar-accent Sidebar hover fill.
--color-sidebar-accent-foreground
#030119 → #e8e6fe
text-sidebar-accent-foreground Ink on sidebar hover.
--color-sidebar-border
#e2dff6 → #2c2668
border-sidebar-border Sidebar hairline.
--color-sidebar-ring
#1d09f6 → #6e62f9
ring-sidebar-ring Sidebar focus ring.
Typography, radius, shadow, space
Bricolage Grotesque carries the whole product: variable on the web, four static weights (400/500/600/700) in the native apps. The marketing site heads big and tight; the app runs 2–4px smaller and leans on weight rather than size. Numbers are always tabular so balances don't jitter as they refresh.
Editorial scale
Marketing pages, auth and the onboarding wizard
Three steps to your first payout
36px / 600 / -0.9px
Marketing section heads, wizard H1 ('Make your profile').
See the rate. Post the clip.
32px / 600 / -0.02em
Secondary marketing heads, mobile page titles ('Discover campaigns').
Find one worth clipping
20px / 600 / -0.01em
Card group heads, dialog titles, FAQ questions.
Loopd is the clipping platform that pays creators per verified view, on every clip they post.
16px / 24px / 400
Marketing body copy and page subtitles.
CAMPAIGNS ON LOOPD
11px / 600 / uppercase / 0.08em
Section chips above every marketing heading; `eyebrow` utility.
Product scale
Signed-in app: Home, Campaigns, Add Clips, Earnings
Discover campaigns
24px / 700 desktop · 30–32px / 700 mobile
PageHeader title. Mobile bumps a step; desktop stays tight.
My submissions (12)
18px / 500
Card section heads inside the campaign modal and settings.
Outlast the Night, Win $250,000
13–15px / 600
Campaign titles, list-row titles, stat labels.
Your views start counting from the moment you submit.
13–14px / 400
The app's default body size: smaller than the marketing site.
16h ago • Per View
12px / 400
Timestamps, secondary rows, helper text under fields.
PER 1M VIEWS
9–11px / 600 / uppercase / 0.04–0.08em
PER 1M VIEWS, LIVE RATE, PAID OUT, CLIPPER, field labels.
$1,500
22–33px / 700–800 / tabular-nums
Payout figures, balances, stat values. Always tabular.
Weights
Variable 200–800; these four are the ones the product uses
Get views
Regular 400Body copy, values, table cells.
Get views
Medium 500Section heads, buttons on marketing.
Get views
Semibold 600App headings, card titles, CTAs.
Get views
Bold 700Page titles, payouts, big numbers.
Radius
--radius is 0.75rem; the rest are derived in @theme inline
6px
10px
12px
16px
20px
24px
36px
9999px
- rounded-sm
- 6px
- Checkbox, tiny chips, table cell affordances.
- rounded-md
- 10px
- Birthday OTP slots, menu items, small icon wells.
- rounded-lg
- 12px
- --radius base. Alerts, nav active tab, toasts.
- rounded-xl
- 16px
- Stat cards, banner cards, link rows, inputs.
- rounded-2xl
- 20px
- Section cards, lifecycle card, stats strip.
- rounded-3xl
- 24px
- The default card. Campaign cards, empty states, popovers.
- rounded-4xl
- 36px
- Onboarding wizard panel, campaign modal on desktop.
- rounded-full
- 9999px
- Every button, chip, badge, avatar and tab bar. No exceptions.
Elevation
Two shadows carry the entire product
Resting surface
Floats over content
Dense / nested cards
- shadow-card
- 0 1px 2px rgb(22 22 22 / .04), 0 8px 24px -12px rgb(22 22 22 / .12)
- Cards and panels. Near-invisible by design: Loopd is a flat product.
- shadow-float
- 0 12px 40px -12px rgb(22 22 22 / .18)
- Things that hover over content: bottom tab bar, marketing pill nav, cookie banner.
- shadow-sm
- Tailwind default
- Dense surfaces where shadow-card is too soft (table shell, inline pills).
- shadow-none
- none
- Anything already separated by a 1px border. Never stack a border and a shadow-card.
Spacing and measure
4px base grid (Tailwind default)
- Page gutter
- 16px mobile · 24–32px desktop
- px-4 md:px-6 on every route container.
- Content column
- 560 / 900 / 1040px
- Settings & auth / Home & Campaigns / Add Clips & Earnings.
- Card padding
- 20px mobile · 24px desktop
- p-5 md:p-6: the standard card interior.
- Card gap
- 16–20px
- space-y-4 md:space-y-5 between stacked cards.
- Section gap
- 32px
- space-y-8 between page sections.
- Control height
- 36 / 44 / 56px
- Button sm / lg / xl. Inputs are 48–56px on mobile.
- Bottom tab clearance
- 110px
- pb-28 on mobile lists so the floating tab bar never hides a card.
Rule of thumb: containers use px-4 md:px-6, cards use p-5 md:p-6, stacks use space-y-4 inside a card and space-y-8 between page sections.
Form controls
Two field styles ship: the shadcn outlined default for dense forms (brand dashboard, filters) and the filled Loopd field for anything a creator types on their phone. Focus is a 2px near-black border on the filled style: no blue glow. Errors color the label, the border and the helper line at once.
Text fields
Filled = the Loopd default; outlined = dense/brand surfaces
FILLED_FIELD: 48px, radius 16px, no border until focus.
3–20 characters. Letters, numbers, underscores and dots.
That username is taken. Pick a different one.
Email is never editable: the settings row drops its pencil icon.
Placeholder vs. entered text
--color-placeholder · #807a9c light / #736b9e dark
Placeholders carry 25% less contrast than text-muted-foreground (3.5:1 on bg-card, 3.1:1 on bg-muted) so the two columns above never read the same at a glance: an empty field must not look answered. The colour is set once, by Input, Textarea, SelectTrigger and the base ::placeholder rule. Never write placeholder:text-muted-foreground in feature code: it is the old value and it wins over all three.
Select, textarea and toggles
Support form. Submit stays disabled until a message is typed.
New campaigns
Email me when a campaign goes live.
Campaign updates
Approvals, rejections and payout news.
ON uses bg-action (#1807ca): the one place blue appears in settings.
Segmented inputs
input-otp: the same primitive powers the email code and the birthday gate
Dev code is 123456. Value: "Not set"
8 boxes, literal separators, letter placeholders. Value: "Not set"
Cards, badges and stats
One card recipe repeats everywhere: white, rounded-3xl, p-5 on mobile and p-6 on desktop, shadow-card, no border. Nested cards drop the shadow and take a hairline instead. Status is always a soft tinted pill: solid fills are reserved for buttons.
Card recipes
rounded-3xl bg-card p-5 md:p-6 shadow-card
Hairline instead of a shadow. Used inside modals and settings sections.
Inset marketing bands: How it works, Active campaigns, For brands.
PageHeader
src/components/shared/page-header.tsx
Discover campaigns
Join a campaign, follow the brief, and get paid for the views your clips generate.
Earnings
Views and pending earnings update every hour.
CampaignCard
src/components/shared/campaign-card.tsx: the most reused component in the product
This account is verified with funds deposited in Loopd.
Canonical order: Instagram, TikTok, YouTube Shorts, X.
The rate tooltip always reads “This rate can move while the campaign runs.”. Pass href for the public board and onSelect for the in-app grid, which opens the campaign modal instead of navigating.
Badges and status pills
src/components/shared/status-badge.tsx
One hue per stage, in lifecycle order.
'Verifying' pulses while the mock runs its checks.
Ledger rows override the label.
`eyebrow` utility class in globals.css
Green-on-green, on every joined campaign header.
StatCard, StatRow and Money
src/components/shared/stat-card.tsx
default $488.92whole $1,500compact $12.1ksigned +$214.60negative -$500.006d
English
Per View
$2.00 cpm
Lifecycle progress uses bg-action; upcoming steps stay on bg-border.
Alerts and banners
EmptyState
src/components/shared/empty-state.tsx
Nothing new
Campaign and payout updates land here.
No available earnings
Submit a clip, clear 5,000 views, and the money lands here.
Optional icon well, one bold line, one gray sentence, at most one black pill. Never two CTAs, never an illustration. Use variant="plain" when the empty state already sits inside a card.
Overlays and feedback
Three overlay shapes. A decision is a ConfirmDialog, centered at every breakpoint. Content flows such as briefs, forms and wizards use a centered dialog on md+ and a vaul bottom sheet below it. ResponsiveDialog picks the container. A transient status step with no controls passes variant="centered" so it never slides up from the bottom edge. Many Loopd dialogs deliberately have no close X and dismiss with the footer button or the overlay; where one is shown it is the shared close pill. Everything asynchronous answers with a sonner toast at the top of the screen.
Tooltips and popovers
Radix, 200ms delay, no arrow
Info circles sit next to every rate and balance label.
Notifications panel and avatar menu use this surface.
Dialog and drawer
Dialog on md+ · Drawer (vaul) below 768px: see ResponsiveDialog
Sheets pin their primary CTA to the bottom with safe-bottom so the home indicator never covers it. The overlay is black/50 in both cases.
ConfirmDialog
src/components/shared/confirm-dialog.tsx: centered at every breakpoint
Every “are you sure?” goes through this one. Radix AlertDialog underneath, so it gets role="alertdialog", focus trapped on the two buttons and no dismiss-on-outside-click. It never closes itself: the caller closes on success, which lets an in-flight request hold it open with pending.
ResponsiveDialog
src/components/shared/responsive-dialog.tsx: dialog on md+, drawer below
Narrow this window past 768px and reopen it: the same call renders a bottom sheet. Every content or task flow goes through this component rather than picking one by hand; a two-button question uses ConfirmDialog instead.
Scroll chrome and the close pill
globals.css subtle-scrollbar · overlayCloseButton in ui/dialog.tsx
A default scrollbar is a grey rectangle, and inside a rounded-3xl surface it draws a hard square edge straight across the corner. Every overlay surface and everything inside it therefore gets subtle-scrollbar automatically: transparent track, a pill thumb held 4px off the edge, and the track inset 14px at both ends so the thumb can never reach a corner. The surface itself is overflow-hidden, so the radius always wins. Nothing in feature code has to opt in; apply the utility by hand only for a scroller that sits on a rounded card outside an overlay. no-scrollbar is still there for chip rails, and still beats this: but content that scrolls should say so.
The close control is one shared pill: 36px of grey with a 44px tap target painted by a transparent ::before, its own fill so it stays readable over a header image or over content sliding underneath, and a 3px ring-ring/40 on keyboard focus. Many Loopd dialogs omit the close button with showCloseButton={false} and dismiss through the footer button or the overlay; the mobile sheet always closes by drag or overlay.
Toasts
sonner, richColors, top-center
Anything the API throws goes through toastApiError(error) unless it belongs to a single field: field errors render inline, never as a toast.
Error copy
Every ApiErrorCode and the surface that renders it
| Code | Message | Surface |
|---|---|---|
| UNAUTHENTICATED | You need to be logged in to do that. | Redirect to /login?redirectTo= |
| OTP_INVALID | That code isn't right. Check your email and try again. | Inline under the OTP boxes |
| OTP_EXPIRED | That code has expired. Request a new one. | Inline + 'Resend code' link |
| UNDERAGE | You must be 18 or older to use Loopd. | Blocking dialog on /birthday |
| REGION_UNSUPPORTED | Loopd isn't available in your country yet. | Full-page block |
| USERNAME_TAKEN | This username already exists. Please choose a new one. | Inline field error |
| USERNAME_INVALID | Usernames are 3-20 characters and can use letters, numbers, underscores and dots. | Inline field error |
| ACCOUNT_REQUIRED | Connect a social account before submitting clips. | Banner in the submit sheet |
| SOCIAL_CONNECT_FAILED | We couldn't connect that account. Please try again. | Toast |
| SOCIAL_ALREADY_LINKED | That account is already linked to your profile. | Toast |
| SOCIAL_CODE_INVALID | That bio code isn't right. Check your profile and try again. | Inline banner in the connect dialog |
| PHONE_REQUIRED | Verify your phone number to join new campaigns. | Banner + phone verify dialog |
| PHONE_CODE_INVALID | That code isn't right. Check your messages and try again. | Inline under the code boxes |
| CAMPAIGN_LOCKED | This campaign is locked for submissions. | Modal banner, Join disabled |
| CAMPAIGN_NOT_FOUND | We couldn't find that campaign. | Empty state on the route |
| NOT_JOINED | Join the campaign before submitting a clip. | Banner in the submit sheet |
| INVALID_URL | That doesn't look like a post link. | Inline field error |
| UNSUPPORTED_URL | That platform isn't supported yet. | Inline field error |
| PLATFORM_NOT_ALLOWED | This campaign doesn't accept posts from that platform. | Inline field error |
| DUPLICATE_POST | This post has already been submitted. | Inline field error |
| DAILY_LIMIT | You've hit the 5 posts per account, per day limit. Try again in 24 hours. | Banner in the submit sheet |
| NO_PAYOUT_METHOD | Add a payout method before cashing out. | Cash-out sheet CTA |
| PAYOUT_METHOD_INVALID | Check that address and try again. | Inline under the address field |
| PAYOUT_METHOD_IN_USE | A payout is on its way to this method. You can remove it once that has been paid. | Toast on the remove confirmation |
| MIN_CASHOUT | You need at least $10 available to cash out. | Inline under the amount |
| CASHOUT_COOLDOWN | Cash outs can be requested once every 7 days. | Disabled CTA + helper line |
| INSUFFICIENT_FUNDS | That's more than your available balance. | Inline under the amount |
| ASSET_TYPE_UNSUPPORTED | That file type isn't supported. Upload MP4, MOV, PNG or JPG. | Rejected before upload, inline on the queue row |
| ASSET_TOO_LARGE | Files have to be 512 MB or smaller. | Rejected before upload, inline on the queue row |
| ASSET_DUPLICATE_NAME | A file with that name is already in this folder. | Failed queue row / inline on rename |
| ASSET_UPLOAD_FAILED | That upload didn't finish. Try again. | Failed queue row + Retry |
| ASSET_QUOTA_EXCEEDED | This campaign's library is full. Delete a few files to make room. | Failed queue row + storage meter warning |
| FOLDER_NAME_TAKEN | You already have a folder with that name. | Inline in the folder dialog |
| BUSINESS_UNVERIFIED | Verify your business before you launch a campaign. | Banner on the wizard review step |
| VERIFICATION_IN_REVIEW | Your business details are still under review. We'll email you when it's done. | Pending badge on the verification card |
| NO_FUNDING_METHOD | Add a funding method before you top up your wallet. | Top-up modal CTA |
| CARD_DECLINED | Your bank declined that card. Try another one or use ACH. | Inline under the card number |
| TEAM_MEMBER_EXISTS | That email is already on your team. | Inline under the invite email |
| LAST_OWNER | Every brand account needs an owner. Make someone else an owner first. | Toast + disabled role menu item |
| FORBIDDEN | Your role doesn't give you access to that. | Full-page block on a gated route |
| DRAFT_NOT_FOUND | That draft no longer exists. | Toast + drafts list refresh |
| ITEM_NOT_FOUND | That clip is no longer in the review queue. | Toast + review queue refresh |
| ALREADY_DECIDED | Someone already reviewed this clip. | Toast + review queue refresh |
| NOT_APPEALABLE | This decision cannot be appealed. | Inline, on the denied clip |
| APPEAL_WINDOW_CLOSED | The time to appeal this has passed. | Inline, on the denied clip |
| APPEAL_EXISTS | You have already appealed this decision. | Inline + link to the open appeal |
| APPEAL_LIMIT | You have five appeals open. Once one is decided you can file another: nothing will expire while you wait. | Inline, with the reassurance that nothing lapses |
| APPEAL_NOT_FOUND | That appeal no longer exists. | Toast + back to the appeal list |
| SAME_REVIEWER | You made the original decision, so someone else has to settle the appeal. | Toast + appeal stays open |
| NOT_LINKED | Sign in to loopd.live with Discord first, then try this again. | Discord bot: reply pointing at loopd.live sign-in |
| VALIDATION | Please check the highlighted fields and try again. | Inline field errors |
| NETWORK | Network error. Check your connection and try again. | Toast + retry |
| UNKNOWN | Something went wrong. Please try again. | Toast |
Motion, icons and the mobile checklist
Motion is used to soften arrivals, never to entertain. Everything is 150–250ms, opacity plus a small y translation, and it happens once. Icons are lucide outline glyphs at 1.5px stroke; the only filled marks in the product are the platform logos and the black verified seal.
Motion
motion/react: subtle, once, under 250ms
Card enter with a 40ms stagger: the pattern every list uses.
delay 0ms
delay 40ms
delay 80ms
- Page / card enter
- opacity 0→1, y 12→0, 220ms easeOut
- Route content and card grids. Once only: never on re-render.
- Stagger
- 40ms per child, max 6 children
- Campaign grids and list rows. Beyond six it reads as slow.
- Press
- active:scale-[0.98]
- Built into Button. The only transform a user triggers directly.
- Sheet / dialog
- vaul + Radix defaults, ~200ms
- Never customized: the drag handle must feel native.
- Accordion
- 200ms ease-out height
- --animate-accordion-down/up in globals.css.
- Skeleton
- animate-pulse
- The only looping animation in the product.
- Reduced motion
- Respect prefers-reduced-motion
- Fades stay, translations drop. motion/react honors it by default.
Iconography
lucide-react at 16–20px, currentColor, 1.5px stroke
Solid black, ~16–18px, from @/components/icons/platforms. They are brand marks: never recolor them, never wrap them in a colored well.
Mobile-feel checklist
Every screen ships against this list
- Renders correctly at 390px with no horizontal scroll.
- Primary CTA is full-width and pinned at the bottom of the card or sheet.
- Fixed bottom bars use the safe-bottom utility; scrollable lists add pb-28.
- Confirmations stay centered. Content flows use a drawer below 768px and a dialog above it.
- Tap targets are at least 44px tall: button sizes lg and xl exist for this.
- Two-column desktop layouts stack; live-preview panels move above the form or hide.
- Tables become stacked rows or drop non-essential columns (Campaign is the first to go).
- Text inputs are 16px+ on mobile so iOS does not zoom on focus.
- No hover-only affordances: anything behind a hover has a visible mobile equivalent.
- Long titles wrap rather than truncate; cards grow.