Design system
Mehfil is one dark theme, one accent, and a small set of primitives. There is no light mode and no second brand colour, which is less a principle than an admission: a catalogue of golden-era film music wants brass on near-black, and everything else was a distraction.
Colour
Colour is a CSS custom property in OKLCH almost everywhere, rather than a value written into a class string. OKLCH because its lightness is perceptual — the accent and the heart sit at 0.79 and 0.70, so they read as siblings rather than one shouting over the other.
- BrassAccents, the play button, everything that means yes
--primary - HeartFavourites only. Warm red at the brass's own lightness
--heart - GroundThe room the whole app sits in
--background - CardPanels and dialogs
--card - SidebarDarker than the ground, so the rail recedes
--sidebar - MutedSecond-line text: singers, counts, dates
--muted-foreground - BorderWhite at nine per cent, never a grey
--border
Type
Figtree, at one weight range, for everything. It was chosen for the company it keeps: the faces music apps favour are geometric sans, Spotify's own Circular is proprietary, and Figtree is the free one that sits nearest without imitating.
Aa Dil Se Dil Mila Le
Asha Bhosle · Navrang · 1959
Body text sits at 14px with relaxed leading. Second-line detail is muted rather than smaller, so a song row stays two readable lines instead of one readable and one squinted at.
Breakpoints
The one genuinely unusual thing here. Tailwind's breakpoints ask about width; ours ask about height too.
| Variant | Applies when |
|---|---|
| sm: | width ≥ 40rem and height ≥ 30rem |
| md: | width ≥ 48rem and height ≥ 30rem |
| lg: | width ≥ 64rem and height ≥ 30rem |
A phone turned sideways is 844px wide and 390px tall. Judged on width alone it looks like a tablet, so the app used to rearrange itself into a layout meant for a much taller window. Width was standing in for “what kind of device is this”, and rotating a phone changes the width without changing the answer.
Components
shadcn/ui, but on Base UI rather than Radix — the part worth knowing if you are reading the source expecting asChild and finding render props instead.
alert-dialogbadgebuttoncardinputscroll-areaselectseparatorsheetskeletonslidertoggletooltipThirteen primitives, and everything else is composed in the app rather than installed. Lists are virtualised with react-virtuoso, because 3,916 rows is more than a browser will draw happily. Icons are lucide, at one size per context.
Idioms
Four conventions the codebase keeps to. More useful to know than any single component, and easier to show than to describe.
Edges fade rather than stop.
Artwork, overflowing titles and the backdrop all end in a mask gradient instead of a hard line.
<span className="absolute inset-y-0 left-0 w-[4.75rem] overflow-hidden
[mask-image:linear-gradient(to_right,#000_0%,transparent_88%)]
[-webkit-mask-image:linear-gradient(to_right,#000_0%,transparent_88%)]
md:hidden">
<img src={artwork(song.video)} className="size-full object-cover" />
</span>Motion is opt-out everywhere.
Every backdrop ships a still beside its video, and the pair swap on the browser's own setting rather than on a preference we invented.
<video className="… motion-reduce:hidden" poster={poster} />
<img className="hidden … motion-reduce:block" src={poster} />Colour never carries meaning alone.
A toggle that shows its state in the accent also says so in the markup, so it survives a screen reader and a colour filter.
<TooltipTrigger render={
<button
aria-pressed={liked}
aria-label={liked ? "Remove from favourites"
: "Add to favourites"} />
}>
<Heart className={liked ? "fill-current" : ""} />
</TooltipTrigger>Tokens, never literals.
A hex in a class string is where a palette starts drifting. Even shadows quote the token's own value.
bg-primary/15 text-primary border-heart/40
bg-[#d6a854] text-[#f66c6d]