Install in
30 seconds.
Pick your AI tool. Hit copy. Paste it into a fresh chat. Strip everything until only one element remains on screen.
Install the Wardrobe Nodesign theme in my project. I'm using Claude Code / Cursor. Apply every step below as written.
Step 1 — Add this to globals.css:
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
/* =========================================================
Wardrobe — NODESIGN tokens
Almost-invisible minimalism. The absence is the design.
White canvas, Inter at 11-14px, every line earned.
Activate via data-system="nodesign".
========================================================= */
:where([data-system="nodesign"]) {
/* ---- Surface ---- */
--color-bg: #ffffff;
--color-fg: #000000;
--color-fg-soft: rgba(0, 0, 0, 0.4);
--color-fg-faint: rgba(0, 0, 0, 0.1);
--color-fg-trace: rgba(0, 0, 0, 0.05);
--color-image-placeholder: #f5f5f5;
/* Aliases used in reference designs */
--bg: #ffffff;
--fg: #000000;
/* ---- Typography ---- */
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-main: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-body: var(--font-main);
--font-mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
--weight-regular: 400;
--weight-medium: 500;
--label-tracking: 0.1em;
--peripheral-tracking: 0.02em;
--focal-tracking: 0.15em;
--tight-tracking: -0.01em;
--tighter-tracking: -0.02em;
/* ---- Layout ---- */
--pad: 2.5vw;
--pad-mobile: 30px;
/* ---- Easing ---- */
--ease-soft: cubic-bezier(0.16, 1, 0.3, 1);
}
Step 2 — Create components/ui/wardrobe/cn.ts:
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
Step 3 — Create the following 10 component files in components/ui/wardrobe/:
--- components/ui/wardrobe/Button.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ButtonProps = {
variant?: "primary" | "secondary" | "destructive" | "ghost";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn("nds-btn", `nds-btn--${variant}`, size !== "md" && `nds-btn--${size}`, className)}
{...props}
>
{children}
</button>
);
}
--- components/ui/wardrobe/Input.tsx ---
import * as React from "react";
import { cn } from "./cn";
type InputProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Input({
label,
hint,
className,
id,
...props
}: InputProps) {
const inputId = id ?? React.useId();
return (
<div className="nds-field">
{label && (
<label htmlFor={inputId}>{label}</label>
)}
<input id={inputId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Textarea.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TextareaProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export function Textarea({
label,
hint,
className,
id,
...props
}: TextareaProps) {
const textareaId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={textareaId}>{label}</label>}
<textarea id={textareaId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Select.tsx ---
import * as React from "react";
import { cn } from "./cn";
type Option = { value: string; label: string };
type SelectProps = {
options: Option[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
label?: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange">;
export function Select({
options,
value,
onValueChange,
placeholder,
label,
className,
id,
...props
}: SelectProps) {
const selectId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={selectId}>{label}</label>}
<select
id={selectId}
className={cn(className)}
value={value}
onChange={(e) => onValueChange(e.target.value)}
{...props}
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
--- components/ui/wardrobe/Card.tsx ---
import * as React from "react";
import { cn } from "./cn";
type CardProps = {
variant?: "default" | "ghost" | "solid";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
export function Card({
variant = "default",
className,
children,
...props
}: CardProps) {
return (
<div
className={cn("nds-card", variant !== "default" && `nds-card--${variant}`, className)}
{...props}
>
{children}
</div>
);
}
--- components/ui/wardrobe/Badge.tsx ---
import * as React from "react";
import { cn } from "./cn";
type BadgeProps = {
variant?: "default" | "solid" | "soft";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLSpanElement>;
export function Badge({
variant = "default",
className,
children,
...props
}: BadgeProps) {
return (
<span
className={cn("nds-badge", variant !== "default" && `nds-badge--${variant}`, className)}
{...props}
>
{children}
</span>
);
}
--- components/ui/wardrobe/Dialog.tsx ---
import * as React from "react";
type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: React.ReactNode;
};
export function Dialog({ open, onOpenChange, title, children }: DialogProps) {
React.useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onOpenChange(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
if (!open) return null;
return (
<div
className="nds-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => {
if (e.target === e.currentTarget) onOpenChange(false);
}}
>
<div className="nds-dialog">
<h2 className="nds-dialog__title">{title}</h2>
{children}
</div>
</div>
);
}
--- components/ui/wardrobe/Tabs.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TabsProps = {
tabs: Array<{ id: string; label: string }>;
activeId: string;
onTabChange: (id: string) => void;
};
export function Tabs({ tabs, activeId, onTabChange }: TabsProps) {
return (
<div className="nds-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeId}
className={cn("nds-tab", tab.id === activeId && "is-active")}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}
--- components/ui/wardrobe/Switch.tsx ---
import * as React from "react";
type SwitchProps = {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label?: string;
};
export function Switch({ checked, onCheckedChange, label }: SwitchProps) {
return (
<label className="nds-switch" data-checked={checked}>
<span className="nds-switch__track">
<span className="nds-switch__thumb" />
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
/>
{label && <span>{label}</span>}
</label>
);
}
--- components/ui/wardrobe/Toast.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ToastProps = {
title: string;
description?: string;
variant?: "default" | "success" | "error";
};
export function Toast({ title, description, variant = "default" }: ToastProps) {
return (
<div
className={cn("nds-toast", variant !== "default" && `nds-toast--${variant}`)}
role="status"
aria-live="polite"
>
<span className="nds-toast__title">{title}</span>
{description && <span className="nds-toast__desc">{description}</span>}
</div>
);
}
Step 4 — Add globals.css component styles. Append the following to globals.css below the tokens:
/* =========================================================
Wardrobe — NODESIGN globals
Loads Inter, applies decorations only inside
[data-system="nodesign"]. Other pages stay untouched.
========================================================= */
[data-system="nodesign"] *,
[data-system="nodesign"] *::before,
[data-system="nodesign"] *::after {
box-sizing: border-box;
}
[data-system="nodesign"] {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-main);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
/* Body fade-in on entering Nodesign pages. We tag <body data-system="nodesign">
in the layout, so the animation runs once on first paint. */
body[data-system="nodesign"] {
opacity: 0;
animation: nds-fade-in 1.6s var(--ease-soft) forwards;
/* Many Nodesign habitat pages place all visible content with
position: absolute (V O I D, inquiry form). Without a flex
column on the body, the in-flow footer collapses up against
the top of the viewport. Force the body to fill the screen
and let the footer drop to the bottom. */
display: flex;
flex-direction: column;
min-height: 100vh;
}
body[data-system="nodesign"] > .foot {
margin-top: auto;
}
@keyframes nds-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
/* ===========================
PERIPHERAL NAV
(top-left / top-center / top-right text-elements)
=========================== */
.nds-text {
position: absolute;
white-space: nowrap;
will-change: transform;
backface-visibility: hidden;
display: inline-block;
text-decoration: none;
color: inherit;
}
.nds-peripheral {
font-size: 11px;
font-weight: var(--weight-regular);
letter-spacing: var(--peripheral-tracking);
text-transform: capitalize;
/* padding-trick for larger hit area without visual change */
padding: 20px;
margin: -20px;
z-index: 5;
}
.nds-peripheral.is-active span {
text-decoration: underline;
text-underline-offset: 4px;
}
.nds-top-left { top: var(--pad); left: var(--pad); }
.nds-top-center { top: var(--pad); left: 50%; transform: translateX(-50%); }
.nds-top-right { top: var(--pad); right: var(--pad); }
/* magnetic inner span — JS adjusts transform on mousemove */
.nds-magnetic span {
display: inline-block;
transition: transform 0.4s var(--ease-soft);
pointer-events: none;
}
/* ===========================
FOCAL POINT
(V O I D — letter-spaced display text in the middle)
=========================== */
.nds-focal {
font-size: 14px;
font-weight: var(--weight-medium);
letter-spacing: var(--focal-tracking);
padding: 40px;
margin: -40px;
}
.nds-center-absolute {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.nds-focal--display {
/* Used on overview hero — bigger than habitat focal points. */
font-size: 64px;
letter-spacing: 0.08em;
font-weight: var(--weight-medium);
}
/* ===========================
BLUEPRINT BACKGROUND SVG
(faint geometric guide-lines behind content)
=========================== */
.nds-blueprint {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
opacity: 0.04;
z-index: 0;
}
.nds-blueprint svg { width: 100%; height: 100%; }
/* ===========================
PROJECT GRID (work page)
=========================== */
.nds-work {
margin-top: 150px;
padding: 0 var(--pad) 100px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4vw;
position: relative;
z-index: 2;
}
.nds-project {
position: relative;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-project:nth-child(2n) { margin-top: 80px; }
.nds-project:nth-child(2) { animation-delay: 0.15s; }
.nds-project:nth-child(3) { animation-delay: 0.3s; }
.nds-project:nth-child(4) { animation-delay: 0.45s; }
@keyframes nds-slide-up {
to { opacity: 1; transform: translateY(0); }
}
.nds-project-image {
width: 100%;
aspect-ratio: 16 / 10;
background: var(--color-image-placeholder);
overflow: hidden;
margin-bottom: 20px;
transition: transform 1.2s var(--ease-soft);
}
.nds-project:hover .nds-project-image { transform: scale(1.02); }
.nds-project-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nds-project-title {
font-size: 13px;
font-weight: var(--weight-medium);
letter-spacing: var(--tight-tracking);
text-transform: uppercase;
}
.nds-project-year {
font-size: 11px;
color: var(--color-fg-soft);
}
/* ===========================
ABOUT — manifesto + team + awards
=========================== */
.nds-about {
margin-top: 150px;
padding: 0 var(--pad) 100px;
position: relative;
z-index: 2;
}
.nds-manifesto {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4vw;
margin-bottom: 120px;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-editorial-image {
width: 100%;
aspect-ratio: 4 / 5;
background: var(--color-image-placeholder);
overflow: hidden;
}
.nds-manifesto-text {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 40px;
}
.nds-section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 40px;
}
.nds-manifesto-content {
font-size: 24px;
line-height: 1.4;
letter-spacing: var(--tighter-tracking);
font-weight: var(--weight-regular);
max-width: 90%;
}
.nds-section-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 60px;
border-top: 1px solid var(--color-fg-faint);
padding-top: 20px;
}
.nds-team {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2vw;
margin-bottom: 120px;
}
.nds-team-photo {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-image-placeholder);
margin-bottom: 15px;
overflow: hidden;
}
.nds-team-name {
font-size: 13px;
font-weight: var(--weight-medium);
display: block;
}
.nds-team-role {
font-size: 11px;
color: var(--color-fg-soft);
}
.nds-awards {
max-width: 100%;
}
.nds-award-row {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
padding: 20px 0;
border-bottom: 1px solid var(--color-fg-trace);
font-size: 13px;
transition: opacity 0.3s ease;
}
.nds-award-row:hover { opacity: 0.5; }
.nds-award-year { color: var(--color-fg-soft); }
.nds-award-status {
text-align: right;
color: var(--color-fg-soft);
}
/* ===========================
INQUIRY FORM
=========================== */
.nds-form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(420px, 90vw);
display: flex;
flex-direction: column;
gap: 36px;
z-index: 3;
}
.nds-field { position: relative; width: 100%; }
.nds-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin-bottom: 8px;
color: var(--color-fg-soft);
}
.nds-field input,
.nds-field textarea,
.nds-field select {
width: 100%;
border: none;
border-bottom: 1px solid var(--color-fg-faint);
background: transparent;
padding: 8px 0;
font-family: var(--font-main);
font-size: 13px;
color: var(--color-fg);
outline: none;
transition: border-color 0.4s ease;
border-radius: 0;
}
.nds-field input:focus,
.nds-field textarea:focus,
.nds-field select:focus {
border-bottom-color: rgba(0, 0, 0, 0.8);
}
.nds-field textarea {
resize: none;
height: 70px;
font-family: var(--font-main);
}
.nds-send {
align-self: flex-start;
font-size: 12px;
font-weight: var(--weight-medium);
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
margin-top: 10px;
color: inherit;
transition: opacity 0.3s ease;
}
.nds-send:hover { opacity: 0.6; }
/* ===========================
STANDARD COMPONENT CLASSES
(Button / Badge / Card / Tabs / Switch / Toast / Dialog —
minimal Nodesign treatment for the install contract)
=========================== */
.nds-btn {
font-family: var(--font-main);
font-weight: var(--weight-medium);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
color: var(--color-fg);
transition: opacity 0.3s ease;
border-bottom: 1px solid var(--color-fg-faint);
border-radius: 0;
line-height: 1.2;
display: inline-flex;
align-items: center;
gap: 0.5em;
}
.nds-btn:hover { opacity: 0.55; border-bottom-color: var(--color-fg); }
.nds-btn:disabled { opacity: 0.25; }
.nds-btn--primary {
border-bottom-color: var(--color-fg);
}
.nds-btn--secondary {
border-bottom-color: var(--color-fg-faint);
}
.nds-btn--ghost {
border-bottom-color: transparent;
}
.nds-btn--destructive {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-btn--sm { font-size: 10px; padding: 6px 0; }
.nds-btn--lg { font-size: 14px; padding: 14px 0; }
.nds-card {
background: transparent;
border: 1px solid var(--color-fg-faint);
padding: 32px;
color: var(--color-fg);
}
.nds-card--ghost { border-color: var(--color-fg-trace); }
.nds-card--solid { background: var(--color-image-placeholder); border-color: transparent; }
.nds-badge {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
padding: 4px 10px;
border: 1px solid var(--color-fg-faint);
color: var(--color-fg);
line-height: 1.2;
background: transparent;
}
.nds-badge--solid {
background: var(--color-fg);
color: var(--color-bg);
border-color: var(--color-fg);
}
.nds-badge--soft { color: var(--color-fg-soft); }
.nds-tabs {
display: flex;
gap: 28px;
border-bottom: 1px solid var(--color-fg-faint);
}
.nds-tab {
background: none;
border: none;
padding: 12px 0;
font-family: var(--font-main);
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: -1px;
border-bottom: 1px solid transparent;
transition: color 200ms ease, border-color 200ms ease;
}
.nds-tab.is-active {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-tab:hover { color: var(--color-fg); }
.nds-switch {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
}
.nds-switch__track {
width: 36px;
height: 1px;
background: var(--color-fg-faint);
position: relative;
}
.nds-switch__thumb {
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
background: var(--color-fg);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: left 240ms var(--ease-soft);
}
.nds-switch[data-checked="true"] .nds-switch__thumb { left: 36px; }
.nds-switch[data-checked="true"] { color: var(--color-fg); }
.nds-toast {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 14px 18px;
color: var(--color-fg);
font-family: var(--font-main);
display: flex;
flex-direction: column;
gap: 4px;
min-width: 240px;
}
.nds-toast__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
}
.nds-toast__desc {
font-size: 12px;
color: var(--color-fg-soft);
}
.nds-toast--success { border-left: 1px solid var(--color-fg); }
.nds-toast--error { border-left: 1px solid var(--color-fg); }
.nds-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.nds-dialog {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 40px;
max-width: 480px;
width: 100%;
color: var(--color-fg);
}
.nds-dialog__title {
font-size: 14px;
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin: 0 0 18px;
}
/* ===========================
RESPONSIVE
=========================== */
@media (max-width: 720px) {
[data-system="nodesign"] { --pad: var(--pad-mobile); }
.nds-work { grid-template-columns: 1fr; gap: 30px; }
.nds-project:nth-child(2n) { margin-top: 0; }
.nds-manifesto { grid-template-columns: 1fr; gap: 30px; }
.nds-team { grid-template-columns: 1fr 1fr; gap: 20px; }
.nds-focal--display { font-size: 36px; }
.nds-form { width: calc(100vw - 60px); }
}
Step 5 — Wrap the section/page you want themed with <div data-system="nodesign">:
<div data-system="nodesign">
<Button>HELLO NODESIGN</Button>
<Card>...</Card>
</div>
Done. Now <Button>, <Input>, <Card>, <Badge>, <Dialog>, <Tabs>, <Switch>, <Toast>, <Select>, <Textarea> render in Nodesign.
Install the Wardrobe Nodesign theme in this v0 project. Treat each step below as a concrete file or edit you must make.
Step 1 — Add this to globals.css:
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
/* =========================================================
Wardrobe — NODESIGN tokens
Almost-invisible minimalism. The absence is the design.
White canvas, Inter at 11-14px, every line earned.
Activate via data-system="nodesign".
========================================================= */
:where([data-system="nodesign"]) {
/* ---- Surface ---- */
--color-bg: #ffffff;
--color-fg: #000000;
--color-fg-soft: rgba(0, 0, 0, 0.4);
--color-fg-faint: rgba(0, 0, 0, 0.1);
--color-fg-trace: rgba(0, 0, 0, 0.05);
--color-image-placeholder: #f5f5f5;
/* Aliases used in reference designs */
--bg: #ffffff;
--fg: #000000;
/* ---- Typography ---- */
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-main: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-body: var(--font-main);
--font-mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
--weight-regular: 400;
--weight-medium: 500;
--label-tracking: 0.1em;
--peripheral-tracking: 0.02em;
--focal-tracking: 0.15em;
--tight-tracking: -0.01em;
--tighter-tracking: -0.02em;
/* ---- Layout ---- */
--pad: 2.5vw;
--pad-mobile: 30px;
/* ---- Easing ---- */
--ease-soft: cubic-bezier(0.16, 1, 0.3, 1);
}
Step 2 — Create components/ui/wardrobe/cn.ts:
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
Step 3 — Create the following 10 component files in components/ui/wardrobe/:
--- components/ui/wardrobe/Button.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ButtonProps = {
variant?: "primary" | "secondary" | "destructive" | "ghost";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn("nds-btn", `nds-btn--${variant}`, size !== "md" && `nds-btn--${size}`, className)}
{...props}
>
{children}
</button>
);
}
--- components/ui/wardrobe/Input.tsx ---
import * as React from "react";
import { cn } from "./cn";
type InputProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Input({
label,
hint,
className,
id,
...props
}: InputProps) {
const inputId = id ?? React.useId();
return (
<div className="nds-field">
{label && (
<label htmlFor={inputId}>{label}</label>
)}
<input id={inputId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Textarea.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TextareaProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export function Textarea({
label,
hint,
className,
id,
...props
}: TextareaProps) {
const textareaId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={textareaId}>{label}</label>}
<textarea id={textareaId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Select.tsx ---
import * as React from "react";
import { cn } from "./cn";
type Option = { value: string; label: string };
type SelectProps = {
options: Option[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
label?: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange">;
export function Select({
options,
value,
onValueChange,
placeholder,
label,
className,
id,
...props
}: SelectProps) {
const selectId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={selectId}>{label}</label>}
<select
id={selectId}
className={cn(className)}
value={value}
onChange={(e) => onValueChange(e.target.value)}
{...props}
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
--- components/ui/wardrobe/Card.tsx ---
import * as React from "react";
import { cn } from "./cn";
type CardProps = {
variant?: "default" | "ghost" | "solid";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
export function Card({
variant = "default",
className,
children,
...props
}: CardProps) {
return (
<div
className={cn("nds-card", variant !== "default" && `nds-card--${variant}`, className)}
{...props}
>
{children}
</div>
);
}
--- components/ui/wardrobe/Badge.tsx ---
import * as React from "react";
import { cn } from "./cn";
type BadgeProps = {
variant?: "default" | "solid" | "soft";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLSpanElement>;
export function Badge({
variant = "default",
className,
children,
...props
}: BadgeProps) {
return (
<span
className={cn("nds-badge", variant !== "default" && `nds-badge--${variant}`, className)}
{...props}
>
{children}
</span>
);
}
--- components/ui/wardrobe/Dialog.tsx ---
import * as React from "react";
type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: React.ReactNode;
};
export function Dialog({ open, onOpenChange, title, children }: DialogProps) {
React.useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onOpenChange(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
if (!open) return null;
return (
<div
className="nds-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => {
if (e.target === e.currentTarget) onOpenChange(false);
}}
>
<div className="nds-dialog">
<h2 className="nds-dialog__title">{title}</h2>
{children}
</div>
</div>
);
}
--- components/ui/wardrobe/Tabs.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TabsProps = {
tabs: Array<{ id: string; label: string }>;
activeId: string;
onTabChange: (id: string) => void;
};
export function Tabs({ tabs, activeId, onTabChange }: TabsProps) {
return (
<div className="nds-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeId}
className={cn("nds-tab", tab.id === activeId && "is-active")}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}
--- components/ui/wardrobe/Switch.tsx ---
import * as React from "react";
type SwitchProps = {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label?: string;
};
export function Switch({ checked, onCheckedChange, label }: SwitchProps) {
return (
<label className="nds-switch" data-checked={checked}>
<span className="nds-switch__track">
<span className="nds-switch__thumb" />
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
/>
{label && <span>{label}</span>}
</label>
);
}
--- components/ui/wardrobe/Toast.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ToastProps = {
title: string;
description?: string;
variant?: "default" | "success" | "error";
};
export function Toast({ title, description, variant = "default" }: ToastProps) {
return (
<div
className={cn("nds-toast", variant !== "default" && `nds-toast--${variant}`)}
role="status"
aria-live="polite"
>
<span className="nds-toast__title">{title}</span>
{description && <span className="nds-toast__desc">{description}</span>}
</div>
);
}
Step 4 — Add globals.css component styles. Append the following to globals.css below the tokens:
/* =========================================================
Wardrobe — NODESIGN globals
Loads Inter, applies decorations only inside
[data-system="nodesign"]. Other pages stay untouched.
========================================================= */
[data-system="nodesign"] *,
[data-system="nodesign"] *::before,
[data-system="nodesign"] *::after {
box-sizing: border-box;
}
[data-system="nodesign"] {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-main);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
/* Body fade-in on entering Nodesign pages. We tag <body data-system="nodesign">
in the layout, so the animation runs once on first paint. */
body[data-system="nodesign"] {
opacity: 0;
animation: nds-fade-in 1.6s var(--ease-soft) forwards;
/* Many Nodesign habitat pages place all visible content with
position: absolute (V O I D, inquiry form). Without a flex
column on the body, the in-flow footer collapses up against
the top of the viewport. Force the body to fill the screen
and let the footer drop to the bottom. */
display: flex;
flex-direction: column;
min-height: 100vh;
}
body[data-system="nodesign"] > .foot {
margin-top: auto;
}
@keyframes nds-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
/* ===========================
PERIPHERAL NAV
(top-left / top-center / top-right text-elements)
=========================== */
.nds-text {
position: absolute;
white-space: nowrap;
will-change: transform;
backface-visibility: hidden;
display: inline-block;
text-decoration: none;
color: inherit;
}
.nds-peripheral {
font-size: 11px;
font-weight: var(--weight-regular);
letter-spacing: var(--peripheral-tracking);
text-transform: capitalize;
/* padding-trick for larger hit area without visual change */
padding: 20px;
margin: -20px;
z-index: 5;
}
.nds-peripheral.is-active span {
text-decoration: underline;
text-underline-offset: 4px;
}
.nds-top-left { top: var(--pad); left: var(--pad); }
.nds-top-center { top: var(--pad); left: 50%; transform: translateX(-50%); }
.nds-top-right { top: var(--pad); right: var(--pad); }
/* magnetic inner span — JS adjusts transform on mousemove */
.nds-magnetic span {
display: inline-block;
transition: transform 0.4s var(--ease-soft);
pointer-events: none;
}
/* ===========================
FOCAL POINT
(V O I D — letter-spaced display text in the middle)
=========================== */
.nds-focal {
font-size: 14px;
font-weight: var(--weight-medium);
letter-spacing: var(--focal-tracking);
padding: 40px;
margin: -40px;
}
.nds-center-absolute {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.nds-focal--display {
/* Used on overview hero — bigger than habitat focal points. */
font-size: 64px;
letter-spacing: 0.08em;
font-weight: var(--weight-medium);
}
/* ===========================
BLUEPRINT BACKGROUND SVG
(faint geometric guide-lines behind content)
=========================== */
.nds-blueprint {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
opacity: 0.04;
z-index: 0;
}
.nds-blueprint svg { width: 100%; height: 100%; }
/* ===========================
PROJECT GRID (work page)
=========================== */
.nds-work {
margin-top: 150px;
padding: 0 var(--pad) 100px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4vw;
position: relative;
z-index: 2;
}
.nds-project {
position: relative;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-project:nth-child(2n) { margin-top: 80px; }
.nds-project:nth-child(2) { animation-delay: 0.15s; }
.nds-project:nth-child(3) { animation-delay: 0.3s; }
.nds-project:nth-child(4) { animation-delay: 0.45s; }
@keyframes nds-slide-up {
to { opacity: 1; transform: translateY(0); }
}
.nds-project-image {
width: 100%;
aspect-ratio: 16 / 10;
background: var(--color-image-placeholder);
overflow: hidden;
margin-bottom: 20px;
transition: transform 1.2s var(--ease-soft);
}
.nds-project:hover .nds-project-image { transform: scale(1.02); }
.nds-project-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nds-project-title {
font-size: 13px;
font-weight: var(--weight-medium);
letter-spacing: var(--tight-tracking);
text-transform: uppercase;
}
.nds-project-year {
font-size: 11px;
color: var(--color-fg-soft);
}
/* ===========================
ABOUT — manifesto + team + awards
=========================== */
.nds-about {
margin-top: 150px;
padding: 0 var(--pad) 100px;
position: relative;
z-index: 2;
}
.nds-manifesto {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4vw;
margin-bottom: 120px;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-editorial-image {
width: 100%;
aspect-ratio: 4 / 5;
background: var(--color-image-placeholder);
overflow: hidden;
}
.nds-manifesto-text {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 40px;
}
.nds-section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 40px;
}
.nds-manifesto-content {
font-size: 24px;
line-height: 1.4;
letter-spacing: var(--tighter-tracking);
font-weight: var(--weight-regular);
max-width: 90%;
}
.nds-section-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 60px;
border-top: 1px solid var(--color-fg-faint);
padding-top: 20px;
}
.nds-team {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2vw;
margin-bottom: 120px;
}
.nds-team-photo {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-image-placeholder);
margin-bottom: 15px;
overflow: hidden;
}
.nds-team-name {
font-size: 13px;
font-weight: var(--weight-medium);
display: block;
}
.nds-team-role {
font-size: 11px;
color: var(--color-fg-soft);
}
.nds-awards {
max-width: 100%;
}
.nds-award-row {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
padding: 20px 0;
border-bottom: 1px solid var(--color-fg-trace);
font-size: 13px;
transition: opacity 0.3s ease;
}
.nds-award-row:hover { opacity: 0.5; }
.nds-award-year { color: var(--color-fg-soft); }
.nds-award-status {
text-align: right;
color: var(--color-fg-soft);
}
/* ===========================
INQUIRY FORM
=========================== */
.nds-form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(420px, 90vw);
display: flex;
flex-direction: column;
gap: 36px;
z-index: 3;
}
.nds-field { position: relative; width: 100%; }
.nds-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin-bottom: 8px;
color: var(--color-fg-soft);
}
.nds-field input,
.nds-field textarea,
.nds-field select {
width: 100%;
border: none;
border-bottom: 1px solid var(--color-fg-faint);
background: transparent;
padding: 8px 0;
font-family: var(--font-main);
font-size: 13px;
color: var(--color-fg);
outline: none;
transition: border-color 0.4s ease;
border-radius: 0;
}
.nds-field input:focus,
.nds-field textarea:focus,
.nds-field select:focus {
border-bottom-color: rgba(0, 0, 0, 0.8);
}
.nds-field textarea {
resize: none;
height: 70px;
font-family: var(--font-main);
}
.nds-send {
align-self: flex-start;
font-size: 12px;
font-weight: var(--weight-medium);
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
margin-top: 10px;
color: inherit;
transition: opacity 0.3s ease;
}
.nds-send:hover { opacity: 0.6; }
/* ===========================
STANDARD COMPONENT CLASSES
(Button / Badge / Card / Tabs / Switch / Toast / Dialog —
minimal Nodesign treatment for the install contract)
=========================== */
.nds-btn {
font-family: var(--font-main);
font-weight: var(--weight-medium);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
color: var(--color-fg);
transition: opacity 0.3s ease;
border-bottom: 1px solid var(--color-fg-faint);
border-radius: 0;
line-height: 1.2;
display: inline-flex;
align-items: center;
gap: 0.5em;
}
.nds-btn:hover { opacity: 0.55; border-bottom-color: var(--color-fg); }
.nds-btn:disabled { opacity: 0.25; }
.nds-btn--primary {
border-bottom-color: var(--color-fg);
}
.nds-btn--secondary {
border-bottom-color: var(--color-fg-faint);
}
.nds-btn--ghost {
border-bottom-color: transparent;
}
.nds-btn--destructive {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-btn--sm { font-size: 10px; padding: 6px 0; }
.nds-btn--lg { font-size: 14px; padding: 14px 0; }
.nds-card {
background: transparent;
border: 1px solid var(--color-fg-faint);
padding: 32px;
color: var(--color-fg);
}
.nds-card--ghost { border-color: var(--color-fg-trace); }
.nds-card--solid { background: var(--color-image-placeholder); border-color: transparent; }
.nds-badge {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
padding: 4px 10px;
border: 1px solid var(--color-fg-faint);
color: var(--color-fg);
line-height: 1.2;
background: transparent;
}
.nds-badge--solid {
background: var(--color-fg);
color: var(--color-bg);
border-color: var(--color-fg);
}
.nds-badge--soft { color: var(--color-fg-soft); }
.nds-tabs {
display: flex;
gap: 28px;
border-bottom: 1px solid var(--color-fg-faint);
}
.nds-tab {
background: none;
border: none;
padding: 12px 0;
font-family: var(--font-main);
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: -1px;
border-bottom: 1px solid transparent;
transition: color 200ms ease, border-color 200ms ease;
}
.nds-tab.is-active {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-tab:hover { color: var(--color-fg); }
.nds-switch {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
}
.nds-switch__track {
width: 36px;
height: 1px;
background: var(--color-fg-faint);
position: relative;
}
.nds-switch__thumb {
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
background: var(--color-fg);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: left 240ms var(--ease-soft);
}
.nds-switch[data-checked="true"] .nds-switch__thumb { left: 36px; }
.nds-switch[data-checked="true"] { color: var(--color-fg); }
.nds-toast {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 14px 18px;
color: var(--color-fg);
font-family: var(--font-main);
display: flex;
flex-direction: column;
gap: 4px;
min-width: 240px;
}
.nds-toast__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
}
.nds-toast__desc {
font-size: 12px;
color: var(--color-fg-soft);
}
.nds-toast--success { border-left: 1px solid var(--color-fg); }
.nds-toast--error { border-left: 1px solid var(--color-fg); }
.nds-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.nds-dialog {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 40px;
max-width: 480px;
width: 100%;
color: var(--color-fg);
}
.nds-dialog__title {
font-size: 14px;
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin: 0 0 18px;
}
/* ===========================
RESPONSIVE
=========================== */
@media (max-width: 720px) {
[data-system="nodesign"] { --pad: var(--pad-mobile); }
.nds-work { grid-template-columns: 1fr; gap: 30px; }
.nds-project:nth-child(2n) { margin-top: 0; }
.nds-manifesto { grid-template-columns: 1fr; gap: 30px; }
.nds-team { grid-template-columns: 1fr 1fr; gap: 20px; }
.nds-focal--display { font-size: 36px; }
.nds-form { width: calc(100vw - 60px); }
}
Step 5 — Wrap the section/page you want themed with <div data-system="nodesign">:
<div data-system="nodesign">
<Button>HELLO NODESIGN</Button>
<Card>...</Card>
</div>
Done. Now <Button>, <Input>, <Card>, <Badge>, <Dialog>, <Tabs>, <Switch>, <Toast>, <Select>, <Textarea> render in Nodesign.
Install the Wardrobe Nodesign theme in my Lovable project. Each step below maps to a specific file or change.
Step 1 — Add this to globals.css:
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
/* =========================================================
Wardrobe — NODESIGN tokens
Almost-invisible minimalism. The absence is the design.
White canvas, Inter at 11-14px, every line earned.
Activate via data-system="nodesign".
========================================================= */
:where([data-system="nodesign"]) {
/* ---- Surface ---- */
--color-bg: #ffffff;
--color-fg: #000000;
--color-fg-soft: rgba(0, 0, 0, 0.4);
--color-fg-faint: rgba(0, 0, 0, 0.1);
--color-fg-trace: rgba(0, 0, 0, 0.05);
--color-image-placeholder: #f5f5f5;
/* Aliases used in reference designs */
--bg: #ffffff;
--fg: #000000;
/* ---- Typography ---- */
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-main: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-body: var(--font-main);
--font-mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, monospace;
--weight-regular: 400;
--weight-medium: 500;
--label-tracking: 0.1em;
--peripheral-tracking: 0.02em;
--focal-tracking: 0.15em;
--tight-tracking: -0.01em;
--tighter-tracking: -0.02em;
/* ---- Layout ---- */
--pad: 2.5vw;
--pad-mobile: 30px;
/* ---- Easing ---- */
--ease-soft: cubic-bezier(0.16, 1, 0.3, 1);
}
Step 2 — Create components/ui/wardrobe/cn.ts:
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
Step 3 — Create the following 10 component files in components/ui/wardrobe/:
--- components/ui/wardrobe/Button.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ButtonProps = {
variant?: "primary" | "secondary" | "destructive" | "ghost";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn("nds-btn", `nds-btn--${variant}`, size !== "md" && `nds-btn--${size}`, className)}
{...props}
>
{children}
</button>
);
}
--- components/ui/wardrobe/Input.tsx ---
import * as React from "react";
import { cn } from "./cn";
type InputProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Input({
label,
hint,
className,
id,
...props
}: InputProps) {
const inputId = id ?? React.useId();
return (
<div className="nds-field">
{label && (
<label htmlFor={inputId}>{label}</label>
)}
<input id={inputId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Textarea.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TextareaProps = {
size?: "sm" | "md" | "lg";
variant?: "default" | "error";
label?: string;
hint?: string;
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export function Textarea({
label,
hint,
className,
id,
...props
}: TextareaProps) {
const textareaId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={textareaId}>{label}</label>}
<textarea id={textareaId} className={cn(className)} {...props} />
{hint && (
<span style={{ display: "block", marginTop: 6, fontSize: 10, color: "rgba(0,0,0,0.4)", textTransform: "uppercase", letterSpacing: "0.1em" }}>
{hint}
</span>
)}
</div>
);
}
--- components/ui/wardrobe/Select.tsx ---
import * as React from "react";
import { cn } from "./cn";
type Option = { value: string; label: string };
type SelectProps = {
options: Option[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
label?: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange">;
export function Select({
options,
value,
onValueChange,
placeholder,
label,
className,
id,
...props
}: SelectProps) {
const selectId = id ?? React.useId();
return (
<div className="nds-field">
{label && <label htmlFor={selectId}>{label}</label>}
<select
id={selectId}
className={cn(className)}
value={value}
onChange={(e) => onValueChange(e.target.value)}
{...props}
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
--- components/ui/wardrobe/Card.tsx ---
import * as React from "react";
import { cn } from "./cn";
type CardProps = {
variant?: "default" | "ghost" | "solid";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
export function Card({
variant = "default",
className,
children,
...props
}: CardProps) {
return (
<div
className={cn("nds-card", variant !== "default" && `nds-card--${variant}`, className)}
{...props}
>
{children}
</div>
);
}
--- components/ui/wardrobe/Badge.tsx ---
import * as React from "react";
import { cn } from "./cn";
type BadgeProps = {
variant?: "default" | "solid" | "soft";
children: React.ReactNode;
} & React.HTMLAttributes<HTMLSpanElement>;
export function Badge({
variant = "default",
className,
children,
...props
}: BadgeProps) {
return (
<span
className={cn("nds-badge", variant !== "default" && `nds-badge--${variant}`, className)}
{...props}
>
{children}
</span>
);
}
--- components/ui/wardrobe/Dialog.tsx ---
import * as React from "react";
type DialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: React.ReactNode;
};
export function Dialog({ open, onOpenChange, title, children }: DialogProps) {
React.useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onOpenChange(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
if (!open) return null;
return (
<div
className="nds-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => {
if (e.target === e.currentTarget) onOpenChange(false);
}}
>
<div className="nds-dialog">
<h2 className="nds-dialog__title">{title}</h2>
{children}
</div>
</div>
);
}
--- components/ui/wardrobe/Tabs.tsx ---
import * as React from "react";
import { cn } from "./cn";
type TabsProps = {
tabs: Array<{ id: string; label: string }>;
activeId: string;
onTabChange: (id: string) => void;
};
export function Tabs({ tabs, activeId, onTabChange }: TabsProps) {
return (
<div className="nds-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeId}
className={cn("nds-tab", tab.id === activeId && "is-active")}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}
--- components/ui/wardrobe/Switch.tsx ---
import * as React from "react";
type SwitchProps = {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label?: string;
};
export function Switch({ checked, onCheckedChange, label }: SwitchProps) {
return (
<label className="nds-switch" data-checked={checked}>
<span className="nds-switch__track">
<span className="nds-switch__thumb" />
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
/>
{label && <span>{label}</span>}
</label>
);
}
--- components/ui/wardrobe/Toast.tsx ---
import * as React from "react";
import { cn } from "./cn";
type ToastProps = {
title: string;
description?: string;
variant?: "default" | "success" | "error";
};
export function Toast({ title, description, variant = "default" }: ToastProps) {
return (
<div
className={cn("nds-toast", variant !== "default" && `nds-toast--${variant}`)}
role="status"
aria-live="polite"
>
<span className="nds-toast__title">{title}</span>
{description && <span className="nds-toast__desc">{description}</span>}
</div>
);
}
Step 4 — Add globals.css component styles. Append the following to globals.css below the tokens:
/* =========================================================
Wardrobe — NODESIGN globals
Loads Inter, applies decorations only inside
[data-system="nodesign"]. Other pages stay untouched.
========================================================= */
[data-system="nodesign"] *,
[data-system="nodesign"] *::before,
[data-system="nodesign"] *::after {
box-sizing: border-box;
}
[data-system="nodesign"] {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-main);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
/* Body fade-in on entering Nodesign pages. We tag <body data-system="nodesign">
in the layout, so the animation runs once on first paint. */
body[data-system="nodesign"] {
opacity: 0;
animation: nds-fade-in 1.6s var(--ease-soft) forwards;
/* Many Nodesign habitat pages place all visible content with
position: absolute (V O I D, inquiry form). Without a flex
column on the body, the in-flow footer collapses up against
the top of the viewport. Force the body to fill the screen
and let the footer drop to the bottom. */
display: flex;
flex-direction: column;
min-height: 100vh;
}
body[data-system="nodesign"] > .foot {
margin-top: auto;
}
@keyframes nds-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
/* ===========================
PERIPHERAL NAV
(top-left / top-center / top-right text-elements)
=========================== */
.nds-text {
position: absolute;
white-space: nowrap;
will-change: transform;
backface-visibility: hidden;
display: inline-block;
text-decoration: none;
color: inherit;
}
.nds-peripheral {
font-size: 11px;
font-weight: var(--weight-regular);
letter-spacing: var(--peripheral-tracking);
text-transform: capitalize;
/* padding-trick for larger hit area without visual change */
padding: 20px;
margin: -20px;
z-index: 5;
}
.nds-peripheral.is-active span {
text-decoration: underline;
text-underline-offset: 4px;
}
.nds-top-left { top: var(--pad); left: var(--pad); }
.nds-top-center { top: var(--pad); left: 50%; transform: translateX(-50%); }
.nds-top-right { top: var(--pad); right: var(--pad); }
/* magnetic inner span — JS adjusts transform on mousemove */
.nds-magnetic span {
display: inline-block;
transition: transform 0.4s var(--ease-soft);
pointer-events: none;
}
/* ===========================
FOCAL POINT
(V O I D — letter-spaced display text in the middle)
=========================== */
.nds-focal {
font-size: 14px;
font-weight: var(--weight-medium);
letter-spacing: var(--focal-tracking);
padding: 40px;
margin: -40px;
}
.nds-center-absolute {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.nds-focal--display {
/* Used on overview hero — bigger than habitat focal points. */
font-size: 64px;
letter-spacing: 0.08em;
font-weight: var(--weight-medium);
}
/* ===========================
BLUEPRINT BACKGROUND SVG
(faint geometric guide-lines behind content)
=========================== */
.nds-blueprint {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
opacity: 0.04;
z-index: 0;
}
.nds-blueprint svg { width: 100%; height: 100%; }
/* ===========================
PROJECT GRID (work page)
=========================== */
.nds-work {
margin-top: 150px;
padding: 0 var(--pad) 100px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4vw;
position: relative;
z-index: 2;
}
.nds-project {
position: relative;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-project:nth-child(2n) { margin-top: 80px; }
.nds-project:nth-child(2) { animation-delay: 0.15s; }
.nds-project:nth-child(3) { animation-delay: 0.3s; }
.nds-project:nth-child(4) { animation-delay: 0.45s; }
@keyframes nds-slide-up {
to { opacity: 1; transform: translateY(0); }
}
.nds-project-image {
width: 100%;
aspect-ratio: 16 / 10;
background: var(--color-image-placeholder);
overflow: hidden;
margin-bottom: 20px;
transition: transform 1.2s var(--ease-soft);
}
.nds-project:hover .nds-project-image { transform: scale(1.02); }
.nds-project-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nds-project-title {
font-size: 13px;
font-weight: var(--weight-medium);
letter-spacing: var(--tight-tracking);
text-transform: uppercase;
}
.nds-project-year {
font-size: 11px;
color: var(--color-fg-soft);
}
/* ===========================
ABOUT — manifesto + team + awards
=========================== */
.nds-about {
margin-top: 150px;
padding: 0 var(--pad) 100px;
position: relative;
z-index: 2;
}
.nds-manifesto {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4vw;
margin-bottom: 120px;
opacity: 0;
transform: translateY(20px);
animation: nds-slide-up 1.2s var(--ease-soft) forwards;
}
.nds-editorial-image {
width: 100%;
aspect-ratio: 4 / 5;
background: var(--color-image-placeholder);
overflow: hidden;
}
.nds-manifesto-text {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 40px;
}
.nds-section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 40px;
}
.nds-manifesto-content {
font-size: 24px;
line-height: 1.4;
letter-spacing: var(--tighter-tracking);
font-weight: var(--weight-regular);
max-width: 90%;
}
.nds-section-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: 60px;
border-top: 1px solid var(--color-fg-faint);
padding-top: 20px;
}
.nds-team {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2vw;
margin-bottom: 120px;
}
.nds-team-photo {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-image-placeholder);
margin-bottom: 15px;
overflow: hidden;
}
.nds-team-name {
font-size: 13px;
font-weight: var(--weight-medium);
display: block;
}
.nds-team-role {
font-size: 11px;
color: var(--color-fg-soft);
}
.nds-awards {
max-width: 100%;
}
.nds-award-row {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
padding: 20px 0;
border-bottom: 1px solid var(--color-fg-trace);
font-size: 13px;
transition: opacity 0.3s ease;
}
.nds-award-row:hover { opacity: 0.5; }
.nds-award-year { color: var(--color-fg-soft); }
.nds-award-status {
text-align: right;
color: var(--color-fg-soft);
}
/* ===========================
INQUIRY FORM
=========================== */
.nds-form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(420px, 90vw);
display: flex;
flex-direction: column;
gap: 36px;
z-index: 3;
}
.nds-field { position: relative; width: 100%; }
.nds-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin-bottom: 8px;
color: var(--color-fg-soft);
}
.nds-field input,
.nds-field textarea,
.nds-field select {
width: 100%;
border: none;
border-bottom: 1px solid var(--color-fg-faint);
background: transparent;
padding: 8px 0;
font-family: var(--font-main);
font-size: 13px;
color: var(--color-fg);
outline: none;
transition: border-color 0.4s ease;
border-radius: 0;
}
.nds-field input:focus,
.nds-field textarea:focus,
.nds-field select:focus {
border-bottom-color: rgba(0, 0, 0, 0.8);
}
.nds-field textarea {
resize: none;
height: 70px;
font-family: var(--font-main);
}
.nds-send {
align-self: flex-start;
font-size: 12px;
font-weight: var(--weight-medium);
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
margin-top: 10px;
color: inherit;
transition: opacity 0.3s ease;
}
.nds-send:hover { opacity: 0.6; }
/* ===========================
STANDARD COMPONENT CLASSES
(Button / Badge / Card / Tabs / Switch / Toast / Dialog —
minimal Nodesign treatment for the install contract)
=========================== */
.nds-btn {
font-family: var(--font-main);
font-weight: var(--weight-medium);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
background: none;
border: none;
padding: 10px 0;
color: var(--color-fg);
transition: opacity 0.3s ease;
border-bottom: 1px solid var(--color-fg-faint);
border-radius: 0;
line-height: 1.2;
display: inline-flex;
align-items: center;
gap: 0.5em;
}
.nds-btn:hover { opacity: 0.55; border-bottom-color: var(--color-fg); }
.nds-btn:disabled { opacity: 0.25; }
.nds-btn--primary {
border-bottom-color: var(--color-fg);
}
.nds-btn--secondary {
border-bottom-color: var(--color-fg-faint);
}
.nds-btn--ghost {
border-bottom-color: transparent;
}
.nds-btn--destructive {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-btn--sm { font-size: 10px; padding: 6px 0; }
.nds-btn--lg { font-size: 14px; padding: 14px 0; }
.nds-card {
background: transparent;
border: 1px solid var(--color-fg-faint);
padding: 32px;
color: var(--color-fg);
}
.nds-card--ghost { border-color: var(--color-fg-trace); }
.nds-card--solid { background: var(--color-image-placeholder); border-color: transparent; }
.nds-badge {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
padding: 4px 10px;
border: 1px solid var(--color-fg-faint);
color: var(--color-fg);
line-height: 1.2;
background: transparent;
}
.nds-badge--solid {
background: var(--color-fg);
color: var(--color-bg);
border-color: var(--color-fg);
}
.nds-badge--soft { color: var(--color-fg-soft); }
.nds-tabs {
display: flex;
gap: 28px;
border-bottom: 1px solid var(--color-fg-faint);
}
.nds-tab {
background: none;
border: none;
padding: 12px 0;
font-family: var(--font-main);
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
margin-bottom: -1px;
border-bottom: 1px solid transparent;
transition: color 200ms ease, border-color 200ms ease;
}
.nds-tab.is-active {
color: var(--color-fg);
border-bottom-color: var(--color-fg);
}
.nds-tab:hover { color: var(--color-fg); }
.nds-switch {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
color: var(--color-fg-soft);
}
.nds-switch__track {
width: 36px;
height: 1px;
background: var(--color-fg-faint);
position: relative;
}
.nds-switch__thumb {
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
background: var(--color-fg);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: left 240ms var(--ease-soft);
}
.nds-switch[data-checked="true"] .nds-switch__thumb { left: 36px; }
.nds-switch[data-checked="true"] { color: var(--color-fg); }
.nds-toast {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 14px 18px;
color: var(--color-fg);
font-family: var(--font-main);
display: flex;
flex-direction: column;
gap: 4px;
min-width: 240px;
}
.nds-toast__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--label-tracking);
}
.nds-toast__desc {
font-size: 12px;
color: var(--color-fg-soft);
}
.nds-toast--success { border-left: 1px solid var(--color-fg); }
.nds-toast--error { border-left: 1px solid var(--color-fg); }
.nds-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.nds-dialog {
background: var(--color-bg);
border: 1px solid var(--color-fg-faint);
padding: 40px;
max-width: 480px;
width: 100%;
color: var(--color-fg);
}
.nds-dialog__title {
font-size: 14px;
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--label-tracking);
margin: 0 0 18px;
}
/* ===========================
RESPONSIVE
=========================== */
@media (max-width: 720px) {
[data-system="nodesign"] { --pad: var(--pad-mobile); }
.nds-work { grid-template-columns: 1fr; gap: 30px; }
.nds-project:nth-child(2n) { margin-top: 0; }
.nds-manifesto { grid-template-columns: 1fr; gap: 30px; }
.nds-team { grid-template-columns: 1fr 1fr; gap: 20px; }
.nds-focal--display { font-size: 36px; }
.nds-form { width: calc(100vw - 60px); }
}
Step 5 — Wrap the section/page you want themed with <div data-system="nodesign">:
<div data-system="nodesign">
<Button>HELLO NODESIGN</Button>
<Card>...</Card>
</div>
Done. Now <Button>, <Input>, <Card>, <Badge>, <Dialog>, <Tabs>, <Switch>, <Toast>, <Select>, <Textarea> render in Nodesign.
Once installed, wrap whichever section you want themed:
<div data-system="nodesign">
<Button variant="primary">Send →</Button>
<Card>Almost nothing</Card>
</div> See it in the wild → Essence →
Got it working?
Drop your email — get the next theme + occasional builder notes.
No spam. ~1 email per theme drop. Maybe one extra when I ship something interesting.