Skip to contentGet startedTokensComponents@hackclub/theme@hackclub/tokens@hackclub/css-base@hackclub/components@hackclub/meta GitHub

CDN

The quickest start. hackclub.css contains the tokens, fonts, element defaults and prose styles; hackclub.js registers every component, with Lit bundled in.

<!doctype html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="https://unpkg.com/@hackclub/theme@2/dist/hackclub.css" />
    <script type="module" src="https://unpkg.com/@hackclub/theme@2/dist/hackclub.js"></script>
  </head>
  <body>
    <hc-nav>
      <a href="/about">About</a>
      <hc-button slot="cta" size="sm">Join</hc-button>
    </hc-nav>
    <hc-section>
      <h1 class="hc-display">Hello, world</h1>
    </hc-section>
    <hc-footer></hc-footer>
  </body>
</html>

npm

bun add @hackclub/theme
// Fonts, tokens, element defaults and prose styles
import '@hackclub/theme/css'

// Registers every <hc-*> element
import '@hackclub/theme'

// …or only what you use
import '@hackclub/theme/components/button'
import '@hackclub/theme/components/card'

@hackclub/theme bundles every layer. If you only need part of it, install the layers on their own:

bun add @hackclub/tokens       # JS/JSON tokens, tokens.css, tailwind.css
bun add @hackclub/css-base     # fonts + element defaults (CSS only)
bun add @hackclub/components   # the web components
The components fall back to injecting the token stylesheet if the page hasn't loaded it. You still need fonts.css (or hackclub.css) for Phantom Sans and Zarathustra.

Frameworks

The components are custom elements, so you configure them with attributes and listen to their events like any other HTML. They render on the client; server-rendered pages show their light-DOM content until the script loads.

React 19

React 19 passes props to custom elements as properties and wires up custom events with an on prefix.

import '@hackclub/theme/css'
import '@hackclub/theme'

export function Signup() {
  return (
    <hc-email-signup
      button-label="Join"
      onhc-submit={event => subscribe(event.detail.email)}
    />
  )
}

TypeScript doesn't know about custom elements, so you can declare the ones you use:

// hackclub.d.ts: optional JSX types
import type { HcButton, HcEmailSignup } from '@hackclub/theme'

type Props<T> = Partial<Omit<T, keyof HTMLElement>> &
  React.HTMLAttributes<T> & { children?: React.ReactNode }

declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'hc-button': Props<HcButton>
      'hc-email-signup': Props<HcEmailSignup> & {
        'onhc-submit'?: (event: CustomEvent<{ email: string }>) => void
      }
    }
  }
}

Next.js (App Router)

// app/layout.tsx
import '@hackclub/theme/css'
import { themeScript } from '@hackclub/theme'
import { Elements } from './elements'

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </head>
      <body>
        <Elements />
        {children}
      </body>
    </html>
  )
}

// app/elements.tsx
'use client'
import { useEffect } from 'react'
export function Elements() {
  useEffect(() => void import('@hackclub/theme'), [])
  return null
}

Vue

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: { isCustomElement: tag => tag.startsWith('hc-') }
      }
    })
  ]
}

// main.js
import '@hackclub/theme/css'
import '@hackclub/theme'

Svelte

<script>
  import '@hackclub/theme/css'
  import '@hackclub/theme'
</script>

<hc-button variant="red" on:click={() => count++}>Clicked {count} times</hc-button>

Astro

---
import '@hackclub/theme/css'
import { themeScript } from '@hackclub/theme'
---
<html lang="en">
  <head>
    <script is:inline set:html={themeScript} />
  </head>
  <body>
    <hc-button arrow>Hello</hc-button>
    <script>
      import '@hackclub/theme'
    </script>
  </body>
</html>

Tailwind CSS

tailwind.css maps Tailwind v4's theme onto the --hc- custom properties, so utilities change with the color scheme.

/* app.css */
@import 'tailwindcss';
@import '@hackclub/theme/tailwind.css';
@import '@hackclub/theme/fonts.css';
<div class="bg-hc-surface text-hc-foreground rounded-card shadow-raised p-8">
  <h2 class="font-display text-heading">Clubs</h2>
  <p class="text-hc-muted">Utilities follow light and dark mode.</p>
  <a class="text-hc-red-text">Learn more</a>
</div>

Dark mode

With no setup, the theme follows the visitor's system setting. Add <hc-theme-toggle> to let visitors choose, and inline themeScript in your <head> so the saved choice applies before the first paint:

<head>
  <!-- Before your stylesheets: applies the saved theme before first paint -->
  <script>/* paste the value of themeScript from @hackclub/theme here */</script>
</head>

The scheme is resolved in this order: data-theme or .hc-light/.hc-dark on any element, then html.dark, then prefers-color-scheme.

<!-- Force a scheme for part of the page -->
<section data-theme="dark">Always dark</section>
<html class="dark">…</html> <!-- same as hackclub.com and Tailwind -->
import { setTheme, getThemePreference, THEME_CHANGE_EVENT } from '@hackclub/theme'

setTheme('dark') // 'light' | 'dark' | 'system'
document.addEventListener(THEME_CHANGE_EVENT, e => console.log(e.detail.theme))

Customisation

Every token is a custom property you can override. Components also expose their own custom properties and CSS parts, listed on each component page.

:root {
  --hc-max-width: 1400px;
  --hc-radius-card: 24px;
}

/* Restyle one button */
hc-button.signup {
  --hc-button-bg: var(--hc-purple);
  --hc-button-bg-hover: var(--hc-blue);
}

/* Reach inside with CSS parts */
hc-card::part(card) {
  border: 2px dashed var(--hc-red);
}

The raw values are available in JavaScript, and as tokens.json:

import { palette, colors, fonts, radii, cssVar } from '@hackclub/tokens'

palette.red // '#ec3750'
colors.dark.surface // '#1f1f27'
cssVar('radius-card') // 'var(--hc-radius-card)'

Migrating from v1

v1 was a Theme UI theme for the previous hackclub.com. v2 follows the current site's design and drops Theme UI and React entirely. If you need the old theme, @hackclub/theme@1 is still on npm.

  • theme.colors.* → var(--hc-*). The palette is the same, andprimary is --hc-red.
  • The new site pairs Zarathustra headings with Phantom Sans body text, and buttons are always pills.
  • Theme UI variants (Button variant="cta", Card variant="sunken") become components and attributes (<hc-button>,<hc-card variant="outline">).
  • @hackclub/meta is unchanged.
// v1 (Theme UI)
import theme from '@hackclub/theme'
<ThemeProvider theme={theme}>
  <Button variant="cta">Join</Button>
</ThemeProvider>

// v2
import '@hackclub/theme/css'
import '@hackclub/theme'
<hc-button arrow>Join</hc-button>

The design system behind hackclub.com.
Made with <3 by Lamparter.

Get startedTokensComponents@hackclub/theme@hackclub/tokens@hackclub/css-base@hackclub/components@hackclub/metaWebsiteBrand guideIconshackclub/site