Installation
Hack Club Theme v2 comes as CSS plus standard web components, so it works with any framework, or with plain HTML.
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 componentsfonts.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, andprimaryis--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/metais 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>