Skip to content

Design system

The pieces this site is made of

Tokens, components and patterns, live and in both themes.

Press D to flip the theme and watch everything follow. Take what's useful: the tokens are at the end.

Foundations

The decisions everything else is built on. Change a token and every component follows, in both themes.

Colour

src/styles/global.css

Every colour is a role, not a hue: components ask for card or muted-foreground, and the dark theme swaps the values underneath. Values here are read live from the page.

Surfaces

  • page

     

    Behind everything

  • background

     

    Chips, inputs, raised strips

  • card

     

    Cards and panels

  • muted

     

    Tags, quiet fills

  • ink

     

    Always dark: menu, code, tools

Text and lines

  • foreground

     

    Headings, labels

  • body-foreground

     

    Running text

  • muted-foreground

     

    Meta, captions

  • border

     

    Outlines and hard shadows

Accents

  • primary

     

    Scarlet: marker, actions, focus

  • secondary

     

    Inverse of the page

  • ring

     

    Focus ring

Status

  • success

     

    Done, sent, live

  • warning

     

    Careful

  • destructive

     

    Errors, removal

Show code for Colour
Usage
<div class="bg-card text-card-foreground border-2 border-border">…</div>
<p class="text-muted-foreground">…</p>
<span class="bg-primary text-primary-foreground">…</span>

Contrast

Text on its usual background, measured from the current theme. Body text clears AA (4.5:1) everywhere; most pairs clear AAA (7:1). The focus ring only needs 3:1 against what it sits on.

SampleText on backgroundRatioPasses
Aaforeground on page––
Aabody-foreground on page––
Aamuted-foreground on page––
Aamuted-foreground on muted––
Aaprimary-foreground on primary––
Aasecondary-foreground on secondary––
Aaink-foreground on ink––
Aaring on page––

Typography

@fontsource-variable/montserrat · @fontsource-variable/nunito-sans

Montserrat at black weight for headings, Nunito Sans for reading, the system mono for code. Sizes are Tailwind's default scale; large headings get 1.15–1.2 leading so a marker behind a wrapped phrase keeps its gap.

Aa

Montserrat

font-heading · 700–900

Aa

Nunito Sans

font-body · 400–700

Aa

System mono

font-mono · code, keys

Display
Build it right
H2
Section heading
H3
Card title
Lead
A short line that sets up the page.
Body
Running text sits in Nunito Sans at a relaxed line height, so long paragraphs stay easy to read.
Small
Meta, captions and helper text.
Mono
const theme = "neo-brutalist";
Show code for Typography
Usage
<h2 class="font-heading text-3xl font-black tracking-tight sm:text-5xl">…</h2>
<p class="font-body text-base leading-relaxed text-body-foreground">…</p>

Radius and borders

Borders are 2px and near-black (light on dark), the main thing that makes the style. Radius stays modest: rounded-lg for cards, full for chips and dots.

  • rounded-md

  • rounded-lg

  • rounded-4xl

  • rounded-full

  • border

  • border-2

  • border-3

Shadows

--shadow-* in @theme

Hard offset shadows, no blur, in the border colour. Small for chips, the default for controls, xl for media. On hover, things either lift into a bigger shadow or press into a smaller one.

  • shadow-sm

  • shadow

  • shadow-lg

  • shadow-xl

  • shadow-2xl

Show code for Shadows
Usage
<div class="rounded-lg border-2 border-border bg-card shadow-xl">…</div>

Motion

--duration-press · --ease-snap · src/lib/animation.ts

One duration (300ms) and one curve (a quick ease-out) for hovers; GSAP handles entrances and scroll. Everything checks prefers-reduced-motion: with it on, things appear without travelling.

Press

pressableShadow

Lift

Hover me

cardLift

Marker on hover

Hover or focus

HoverMarker

Marker sweep

Sweeps in

Show code for Motion
Usage
import { cardLift, pressableShadow } from '@/lib/utils';

<button class={cn('border-2 border-border bg-primary', pressableShadow)}>Press</button>
<article class={cn('border-2 border-border bg-card', cardLift)}>…</article>

Layout

container-page · section-gap · GuideLines · SectionDivider · GridPattern

An 80rem column with 24px gutters (32px from lg). Two hairline guides run down its edges, dividers cross them with a +, and a slanted block grid sits behind the top of every page; move the pointer over it.

gutter · column · gutter

Show code for Layout
Usage
<Container>…</Container>          <!-- the page column -->
<section class="section-gap">…</section>
<SectionDivider />                 <!-- the gap between page-builder blocks -->

Components

The interface pieces used across the site. Registry components (shadcn-style, from @kunalkeshan) are React; anything that doesn't need state is Astro and ships no JavaScript.

Buttons

src/components/ui/button.tsx · ButtonLink.astro

Solid buttons rest on a hard shadow and press into it. Use <Button> in React and <ButtonLink> for links in Astro; both share buttonVariants, so they always match.

Variants

Sizes

With icons, disabled

Get in touch
Show code for Buttons
Usage
<ButtonLink href="/contact" size="lg">
  Get in touch
  <ArrowRight data-icon="inline-end" aria-hidden="true" />
</ButtonLink>

<Button variant="outline">Cancel</Button>

Badges

src/components/ui/badge.tsx

Small uppercase labels for status and categories. Keep them to a word or two.

defaultsecondarysuccesswarningdestructiveoutlineghostlink
Show code for Badges
Usage
<span class={badgeVariants({ variant: 'success' })}>Live</span>

Marker highlight

src/components/ui/Highlight.astro · HoverMarker.astro · src/lib/marked.ts

The site's signature: a flat marker behind a phrase. In Sanity, wrap words in *asterisks* in a heading and they get the marker. HoverMarker sweeps it in when its link is hovered, focused, or the current page.

Primary marker and secondary

Show code for Marker highlight
Usage
<h2>Things I <Highlight>build with</Highlight></h2>

<a href="/work" class="group">
  <HoverMarker>Work</HoverMarker>
</a>

Section heading

src/components/SectionHeading.astro

How every page-builder block opens: an eyebrow with a scarlet dot, the heading, an optional intro line. Pass invert on dark surfaces.

Toolkit

The tools I build with

Day to day, these are what I reach for.

Show code for Section heading
Usage
<SectionHeading
  eyebrow="Toolkit"
  heading="The tools I *build with*"
  text="Day to day, these are what I reach for."
/>

Filter chips

src/components/FilterChips.astro · src/lib/filter.ts

Toggle buttons (aria-pressed) that filter a list, with a polite live count. wireFilter() animates the change with GSAP Flip, or switches instantly with reduced motion.

6 items

  • Portfolioweb
  • Studio plugintool
  • Landing pageweb
  • CLItool
  • Mobile appapp
  • Dashboardweb
Show code for Filter chips
Usage
<FilterChips filters={[{ value: 'all', label: 'All' }, …]} label="Filter projects" count="6 items" />

<script>
  wireFilter({ root, items, valueOf: (el) => el.dataset.type, noun: ['item', 'items'] });
</script>

Form fields

src/components/ui/input.tsx · textarea.tsx · label.tsx · form.tsx

Fields press in like buttons when hovered. Errors set aria-invalid and point to their message with aria-describedby; the contact form wires this up through react-hook-form and zod.

Enter a valid email address.

Show code for Form fields
Usage
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" aria-invalid={!!error} aria-describedby="email-error" />
<p id="email-error" class="text-sm text-destructive">{error}</p>

Cards

src/components/ui/card.tsx · cardLift in src/lib/utils.ts

The registry card for structured content (header, action, body, footer), and the flat card that lifts into its shadow on hover, used for services and stats.

Headless CMS setup
Sanity, a page builder and live preview.
Live

Editors build pages from blocks; the site rebuilds on publish.

2 weeks

15+

Projects shipped (hover me)

Show code for Cards
Usage
<Card>
  <CardHeader>
    <CardTitle>Title</CardTitle>
    <CardDescription>Description</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter>…</CardFooter>
</Card>

Project card

src/components/ProjectCard.astro

The work card, here with the latest real project. The screenshot drifts as you scroll, a Details bubble follows the pointer, and the title is a stretched link so the whole card is clickable while its name stays the visible title.

  • CAGPT homepage with the headline “Insights Driven by AI” and topic cards for CA, GST and income tax
    website

    CAGPT

    Developed CAGPT, an advanced platform delivering expert, AI-driven insights on Indian finance, taxation, compliance, and corporate law. Hosts 6,000+ articles with integrated AI features, including a CA-focused chat assistant and a “chat with articles” capability for contextual, data-backed responses. Powered by Perplexity’s Sonar model by default with OpenAI integration for extended capabilities. Built with a Directus CMS linked to the same database for seamless client content management, enabling efficient publishing workflows. Designed to provide real-time regulatory updates, business valuations, and automated financial calculators—helping chartered accountants and professionals make informed, compliant, and strategic decisions with speed and accuracy.

    • Next JS
    • Tailwind CSS
    • Shadcn
    • Tanstack React Query
    • +4 more
Show code for Project card
Usage
<ul role="list">
  <ProjectCard project={project} index={0} headingLevel={3} />
</ul>

Frame

src/components/ui/Frame.astro

A bordered box with the large shadow, for images and media. lift adds the hover raise.

Media goes here

Show code for Frame
Usage
<Frame lift class="aspect-video">
  <SanityImage image={image} … />
</Frame>

Dialog

src/components/ui/dialog.tsx (Base UI)

Modal dialogs trap focus, close on Escape and return focus to the trigger. The project details on the work page use it.

Show code for Dialog
Usage
<Dialog>
  <DialogTrigger render={<Button />}>Open</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Title</DialogTitle>
      <DialogDescription>…</DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>

Spinner

src/components/ui/spinner.tsx

For work in progress. It has role=status and a label; inside a button, disable the button and change its text too.

Show code for Spinner
Usage
<Button disabled>
  <Spinner data-icon="inline-start" /> Sending…
</Button>

Content

Long-form writing comes from Sanity as Portable Text and renders through one component, themed once with Tailwind Typography, so every note reads the same.

Rich text

src/components/RichText.astro · .prose in global.css

Headings, marks, lists, quotes and links in the prose rhythm. H2s get anchor ids for the table of contents; off-site links open in a new tab and say so.

Writing on the site goes through one component, so every note and legal page shares this rhythm.

A third-level heading

Text can be bold, emphasised, carry inline code, or link somewhere useful.

  • Lists use the scarlet bullet
  • and keep their items close together
  1. Numbered steps count in muted grey
Quotes get a scarlet rule and the heading colour.
Show code for Rich text
Usage
<RichText value={note.body} />            <!-- 16px → 18px -->
<RichText value={intro} size="lg" />      <!-- 18px → 20px -->

Callouts

src/components/portable-text/Callout.astro → ui/Alert.astro

A short aside in a note: Note, Tip or Warning, chosen in the Studio. Built on the Alert component.

Show code for Callouts
Usage
{ _type: 'callout', tone: 'tip', text: '…' }   // in Sanity
<Alert tone="tip">…</Alert>                    // in code

Code block

src/components/portable-text/CodeBlock.astro · src/lib/codeTheme.ts

Highlighted at build time by Shiki with a theme in the site colours, on an always-dark panel, with the filename and a copy button. Languages Shiki doesn't know (GROQ) show as plain text.

lib/notes.ts
// lib/notes.ts
export const readingMinutes = (words: number) =>
  Math.max(1, Math.round(words / 220));

Table

src/components/portable-text/Table.astro

The first row is the header. Wide tables scroll inside their own focusable region instead of pushing the page sideways.

Where things run
PieceRuns onCost
SiteVercel (static)Free
ContentSanityFree
Share imagesBuild stepFree

Blocks

Pages are assembled in Sanity from these blocks. Each one fetches its own fields with a GROQ projection and renders with the components above.

Stats

src/components/Stats.astro

A real block with sample data. The numbers count up when scrolled into view.

Years building for the web
4+
Projects shipped
15+
Accessibility score
100
Show code for Stats
ts
{ _type: 'stats', items: [{ value: '4+', label: 'Years building for the web' }, …] }

Page builder blocks

src/components/PageBuilder.astro · studio/schemaTypes

Everything an editor can drop onto a page, and where to see it live.

BlockWhat it doesSee it
HeroHero.astroHome opening with the marker headline and the interactive terminal./
Page introPageIntroBlock.astroInner page opening: eyebrow, headline, lead, body./about/
StatsStats.astroNumbers in cards that count up on scroll./about/
ToolsTools.astroTwo marquee rows of logos on dark tiles, fading at the edges./
ServicesServices.astroWhat I offer, as cards with a cursor spotlight./
Featured projectsFeaturedProjects.astroHand-picked project cards with the details dialog./
All projectsAllProjects.astroEvery project, filterable by type./work/
TestimonialsTestimonials.astroQuotes in a carousel./
ProcessProcess.astroNumbered steps with images, sides alternating./process/
TimelineTimeline.astroWork and education on a line that fills as you scroll./about/
Note listPostList.astroNotes with tags and reading time./notes/
Contact formContactForm.astroEnquiry form with validation, plus profiles./contact/

Extras

Ready-made pieces in the same language for things this site doesn't do yet: settings, FAQs, long lists, docs. Each is one Astro file in src/components/ui, keyboard- and screen-reader-ready.

Alert

src/components/ui/Alert.astro

A message set apart from the text, in four tones. title replaces the lead-in; pass an empty string for none.

Show code for Alert
Usage
<Alert tone="danger" title="Heads up">This deletes the project for good.</Alert>

Keyboard keys

src/components/ui/Kbd.astro

For shortcuts in text. A single key in the slot, or a chord with keys.

Press D to switch the theme, Esc to close the menu, or ⌘K to search.

Show code for Keyboard keys
Usage
Press <Kbd>D</Kbd> to switch theme · <Kbd keys={['⌘', 'K']} />

Tooltip

src/components/ui/Tooltip.astro

A short hint on hover and keyboard focus, wired as the control's description and dismissible with Escape. Never put anything essential only in a tooltip.

Copy the linkGitHub Opens GitHub
Show code for Tooltip
Usage
<Tooltip text="Copy the link">
  <button aria-label="Copy link">…</button>
</Tooltip>

Tabs

src/components/ui/Tabs.astro

The ARIA tabs pattern: arrow keys and Home/End move between tabs, Tab enters the panel. Without JavaScript every panel shows under its label.

pnpm

pnpm add gsap
Show code for Tabs
Usage
<Tabs label="Install" tabs={[{ id: 'pnpm', label: 'pnpm' }, { id: 'npm', label: 'npm' }]}>
  <Fragment slot="pnpm"><code>pnpm add gsap</code></Fragment>
  <Fragment slot="npm"><code>npm i gsap</code></Fragment>
</Tabs>

Accordion

src/components/ui/AccordionItem.astro

Native details and summary, so it works without JavaScript and with find-in-page. Items sharing a name close each other.

Do you take freelance work?
Yes. Websites, Sanity setups and front-end work; get in touch through the contact page.
Can I use these components?
Go ahead. Copy the tokens below, then any component file. They rely only on Tailwind and the cn helper.
Why neo-brutalist?
Clear outlines and hard shadows make structure obvious, and they stay legible in both themes.
Show code for Accordion
Usage
<AccordionItem name="faq" summary="Do you take freelance work?">
  Yes, …
</AccordionItem>

Switch

src/components/ui/Switch.astro

For settings that apply straight away. A button with role=switch; listen for its change event.

Show code for Switch
Usage
<Switch label="Compact view" />

<script>
  el.addEventListener('change', (e) => console.log(e.detail.checked));
</script>

Checkbox and radio

src/components/ui/Choice.astro

Native inputs drawn in the site style, with an optional hint linked as the description.

Topics

Static sites with islands

Content and page building

Coming soon

Budget
Show code for Checkbox and radio
Usage
<Choice name="topics" value="astro" label="Astro" hint="Static sites, islands" />
<Choice type="radio" name="budget" value="s" label="Under $5k" />

Progress

src/components/ui/Progress.astro

A labelled progressbar with a striped fill.

Reading progress
Profile complete
Show code for Progress
Usage
<Progress label="Upload" value={64} />

src/components/ui/Breadcrumbs.astro

Where a page sits. The last item is the current page (aria-current) and isn't a link.

Show code for Breadcrumbs
Usage
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Notes', href: '/notes/' }, { label: title }]} />

Pagination

src/components/ui/Pagination.astro

Previous, next and nearby pages, with gaps for long runs. Give it the page count and an href builder.

Show code for Pagination
Usage
<Pagination page={4} pages={12} href={(n) => `/notes/page/${n}/`} />

Sticker

src/components/ui/Sticker.astro

A tilted label for a card or image corner: New, Live, WIP. Use sparingly.

NewLiveWIPOpen source
Show code for Sticker
Usage
<Sticker tone="success" tilt={4}>Live</Sticker>

Use it

Free to borrow. Start with the tokens, then copy the component files you need; they depend on Tailwind CSS v4, the cn() helper (clsx + tailwind-merge) and, for the React ones, Base UI.

Tokens

src/styles/global.css

Paste into your Tailwind v4 stylesheet after @import 'tailwindcss'. Add a .dark class on <html> to switch themes. This is read from the site's own CSS at build time, so it's always current.

tokens.css
@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --font-sans:
    'Mona Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
    'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-display:
    'Mona Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
    'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-display--font-variation-settings: 'wdth' 125;

  --radius-4xl: 2.5rem;

  /* New design system, taken from kunalkeshan.dev (itself modelled on
     Paperfolio): warm off-white, scarlet + ink accents, near-black 2px
     borders and hard offset shadows. Used by src/components/ui and by
     sections that have moved to the new look. */
  --font-heading: 'Montserrat Variable', ui-sans-serif, system-ui, sans-serif;
  --font-body: 'Nunito Sans Variable', ui-sans-serif, system-ui, sans-serif;

  --color-page: #ffffff;
  --color-background: #faf9f6;
  --color-foreground: #0b0b0b;
  --color-body-foreground: #393939;
  --color-card: #ffffff;
  --color-card-foreground: #0b0b0b;
  --color-border: #0b0b0b;
  /* Palette: scarlet + ink. Scarlet sits behind dark text (6.4:1), ink
     behind white text (17.7:1). The focus ring is scarlet so it shows on both the
     light page (3.1:1) and the dark sections (6.4:1). */
  --color-ring: #ff5a4e;
  --color-primary: #ff5a4e;
  --color-primary-foreground: #0b0b0b;
  --color-secondary: #18181b;
  --color-secondary-foreground: #ffffff;
  --color-popover: #ffffff;
  --color-popover-foreground: #0b0b0b;
  --color-overlay: rgb(11 11 11 / 0.55);
  --color-muted: #f1f0ee;
  --color-muted-foreground: #6b6b6b;
  --color-success: #00e07b;
  --color-success-foreground: #0b0b0b;
  --color-warning: #ffb800;
  --color-warning-foreground: #0b0b0b;
  --color-destructive: #ff4d4d;
  --color-destructive-foreground: #0b0b0b;
  /* Always-dark surfaces (menu panel, Tools) in both themes. */
  --color-ink: #0b0b0b;
  --color-ink-foreground: #faf9f6;

  /* Hard offset shadows. */
  --shadow: 4px 4px 0 0 var(--color-border);
  --shadow-sm: 2px 2px 0 0 var(--color-border);
  --shadow-lg: 6px 6px 0 0 var(--color-border);
  --shadow-xl: 8px 8px 0 0 var(--color-border);
  --shadow-2xl: 10px 10px 0 0 var(--color-border);

  --duration-press: 300ms;
  --ease-snap: cubic-bezier(0.16, 1, 0.3, 1);

  /* Page width. Also gives a `max-w-page` utility. */
  --container-page: 80rem;
}

:root.dark {
  color-scheme: dark;
  --color-page: #121212;
  --color-background: #161616;
  --color-foreground: #f4f3ef;
  --color-body-foreground: #cfcdc8;
  --color-card: #1c1c1e;
  --color-card-foreground: #f4f3ef;
  --color-border: #f4f3ef;
  --color-secondary: #f4f3ef;
  --color-secondary-foreground: #0b0b0b;
  --color-popover: #1c1c1e;
  --color-popover-foreground: #f4f3ef;
  --color-overlay: rgb(0 0 0 / 0.7);
  --color-muted: #262628;
  --color-muted-foreground: #a8a6a1;
  --color-ink: #050505;
}

Principles

What to check before calling something done.

  1. 01

    Outline everything that matters

    Interactive and grouped things get a 2px border in the border colour. If it has no border, it isn’t a thing you can touch.

  2. 02

    Shadows are hard, never blurred

    Offset shadows in the border colour. Things lift into a bigger one or press into a smaller one; nothing floats.

  3. 03

    One scarlet moment per view

    The marker, the primary action, the focus ring. Scarlet means “look here”, so spend it carefully.

  4. 04

    Roles, not colours

    Ask for card, muted-foreground or primary, never a hex. Then dark mode is free.

  5. 05

    Motion explains, then gets out of the way

    300ms, ease-out, and nothing moves for people who ask it not to.

  6. 06

    Everything works with a keyboard

    A visible focus ring on every control, real buttons and links, names that match what’s on screen. Tested with axe in both themes.

  7. 07

    No JavaScript unless it earns it

    Astro first. React islands only where there’s state: forms, dialogs, the terminal.