Components
Button, Input, Card, Tabs and Dialog, built on Base UI. Keyboard behaviour, focus management and dismissal come from Base UI. Components carry class names only and never name a colour; a test fails the build if one does.
NostalgiaRoot
import { NostalgiaRoot } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
Source
import { createContext, useContext, useState } from 'react';
import type * as React from 'react';
/**
* The element that carries data-era and data-palette, shared so that portalled
* parts can be rendered back inside it.
*
* Base UI portals Dialog (and Popover, Menu, Tooltip) into document.body by
* default. Because the tokens are scoped to attribute selectors on a wrapper,
* anything portalled to the body escapes them entirely: the dialog rendered
* with no surface, no border and no title bar, because neither the CSS
* variables nor the [data-era] selectors reached it.
*
* Putting the attributes on <html> would also fix it, but would make the whole
* document a single era and rule out showing two side by side, which the
* milestone 4 timeline demo needs. Portalling into the themed element keeps
* theming scoped.
*/
const ContainerContext = createContext<HTMLElement | null>(null);
export function useNostalgiaContainer(): HTMLElement | null {
return useContext(ContainerContext);
}
export interface NostalgiaRootProps extends React.ComponentPropsWithoutRef<'div'> {
/** Shape and feel. */
readonly era: string;
/** Colour. */
readonly palette: string;
}
/**
* Wraps a region in one era and one palette.
*
* Switching either prop changes a single attribute. No component re-renders
* differently and no markup changes: the entire visual difference is CSS
* resolving a different set of custom properties.
*/
export function NostalgiaRoot(props: NostalgiaRootProps): React.ReactElement {
const { era, palette, className, children, ...rest } = props;
const [container, setContainer] = useState<HTMLElement | null>(null);
return (
<div
{...rest}
ref={setContainer}
data-era={era}
data-palette={palette}
className={['nui-root', className].filter(Boolean).join(' ')}
>
<ContainerContext.Provider value={container}>{children}</ContainerContext.Provider>
</div>
);
}
Button
import { Button } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
Source
import { useRender } from '@base-ui/react/use-render';
import { mergeProps } from '@base-ui/react/merge-props';
import type * as React from 'react';
export type ButtonVariant = 'solid' | 'outline' | 'ghost';
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
/** Visual weight. Every variant is styled only through era and palette tokens. */
readonly variant?: ButtonVariant;
/** Replaces the rendered element, matching Base UI's composition contract. */
readonly render?: useRender.RenderProp;
}
/**
* Base UI has no Button primitive, because a button needs no behaviour a
* native button lacks. It does provide useRender, which is the hook behind the
* `render` prop on every Base UI component, so using it here keeps one
* composition contract across the library rather than two.
*/
export function Button(props: ButtonProps): React.ReactElement | null {
const { variant = 'solid', render, className, ...rest } = props;
return useRender({
render,
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: ['nui-Button', 'nui-Button--' + variant, className].filter(Boolean).join(' '),
type: 'button',
},
rest,
),
});
}
Input
import { Input } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
Source
import { Input as BaseInput } from '@base-ui/react/input';
import type * as React from 'react';
export interface InputProps extends React.ComponentProps<typeof BaseInput> {
/** Marks the field as invalid, which eras express differently: a red inset
* border in Classic Windows, a glow in Frutiger Aero, a solid bar in Flat. */
readonly invalid?: boolean;
}
export function Input(props: InputProps): React.ReactElement {
const { invalid = false, className, ...rest } = props;
const classes = ['nui-Input', invalid ? 'nui-Input--invalid' : undefined]
.filter(Boolean)
.join(' ');
return (
<BaseInput
{...rest}
data-invalid={invalid ? '' : undefined}
className={typeof className === 'string' ? classes + ' ' + className : classes}
/>
);
}
Card
import { Card, CardTitle, CardBody } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
Source
import { useRender } from '@base-ui/react/use-render';
import { mergeProps } from '@base-ui/react/merge-props';
import type * as React from 'react';
export interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
/** A card that sits above the page surface rather than flush with it. */
readonly elevated?: boolean;
readonly render?: useRender.RenderProp;
}
/**
* Also not a Base UI primitive: a card is presentation with no interaction
* model. It still goes through useRender so that `render` behaves the same way
* it does on Button and on every Base UI part.
*/
export function Card(props: CardProps): React.ReactElement | null {
const { elevated = false, render, className, ...rest } = props;
return useRender({
render,
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: ['nui-Card', elevated ? 'nui-Card--elevated' : undefined, className]
.filter(Boolean)
.join(' '),
},
rest,
),
});
}
export function CardTitle(props: React.ComponentPropsWithoutRef<'h3'>): React.ReactElement {
const { className, ...rest } = props;
return <h3 {...rest} className={['nui-CardTitle', className].filter(Boolean).join(' ')} />;
}
export function CardBody(props: React.ComponentPropsWithoutRef<'div'>): React.ReactElement {
return <div {...props} className={['nui-CardBody', props.className].filter(Boolean).join(' ')} />;
}
Tabs
import { Tabs, TabList, Tab, TabIndicator, TabPanel } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
- TabIndicator must be placed inside TabList, not beside it.
Source
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
import type * as React from 'react';
/**
* Thin styling wrappers over Base UI's Tabs parts. All keyboard behaviour,
* arrow-key focus and panel association come from Base UI; this file only
* attaches class names.
*
* The parts are re-exported individually rather than collapsed into one
* convenience component, so consumers keep the composition Base UI provides.
*/
export function Tabs(props: React.ComponentProps<typeof BaseTabs.Root>): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseTabs.Root
{...rest}
className={typeof className === 'string' ? 'nui-Tabs ' + className : 'nui-Tabs'}
/>
);
}
export function TabList(props: React.ComponentProps<typeof BaseTabs.List>): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseTabs.List
{...rest}
className={typeof className === 'string' ? 'nui-TabList ' + className : 'nui-TabList'}
/>
);
}
export function Tab(props: React.ComponentProps<typeof BaseTabs.Tab>): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseTabs.Tab
{...rest}
className={typeof className === 'string' ? 'nui-Tab ' + className : 'nui-Tab'}
/>
);
}
/**
* Base UI's Indicator exposes the active tab's geometry as CSS variables
* (--active-tab-left, --active-tab-width and so on), which lets an era decide
* whether to animate it. Classic Windows has no transition, so it jumps.
*/
export function TabIndicator(
props: React.ComponentProps<typeof BaseTabs.Indicator>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseTabs.Indicator
{...rest}
className={
typeof className === 'string' ? 'nui-TabIndicator ' + className : 'nui-TabIndicator'
}
/>
);
}
export function TabPanel(props: React.ComponentProps<typeof BaseTabs.Panel>): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseTabs.Panel
{...rest}
className={typeof className === 'string' ? 'nui-TabPanel ' + className : 'nui-TabPanel'}
/>
);
}
Dialog
import { Dialog, DialogPortal, DialogTrigger, DialogBackdrop, DialogPopup, DialogTitle, DialogDescription, DialogClose } from '@nostalgiaui/react';
import '@nostalgiaui/tokens/tokens.css';
import '@nostalgiaui/react/styles.css';Worth knowing
- Every component must render inside <NostalgiaRoot era="terminal-crt" palette="..."> or inside an element carrying data-era and data-palette. Outside one, the token variables do not resolve and the component renders unstyled.
- Import the two stylesheets once, at the application root. Components carry class names only and never import CSS themselves.
- DialogPortal renders into the nearest NostalgiaRoot rather than document.body. Without a NostalgiaRoot ancestor the dialog portals to the body, escapes the token scope, and appears with no surface, border or title bar.
Source
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import type * as React from 'react';
import { useNostalgiaContainer } from './NostalgiaRoot.js';
/**
* Styling wrappers over Base UI's Dialog parts. Focus trapping, scroll lock
* and dismissal all come from Base UI.
*
* Only the parts needed by the five-component set are wrapped. Root, Portal
* and Close are re-exported unchanged, since Root and Portal render no element
* of their own and so have nothing to style.
*/
export const Dialog = BaseDialog.Root;
/**
* Portals into the nearest NostalgiaRoot rather than into document.body.
*
* Base UI's default sends the popup to the body, where the token variables and
* the [data-era] selectors do not reach it, and the dialog renders unstyled.
* An explicit `container` prop still wins, so this only supplies a default.
*/
export function DialogPortal(
props: React.ComponentProps<typeof BaseDialog.Portal>,
): React.ReactElement {
const container = useNostalgiaContainer();
const { container: explicit, ...rest } = props;
const resolved = explicit ?? container;
return resolved === null || resolved === undefined ? (
<BaseDialog.Portal {...rest} />
) : (
<BaseDialog.Portal {...rest} container={resolved} />
);
}
export function DialogTrigger(
props: React.ComponentProps<typeof BaseDialog.Trigger>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Trigger
{...rest}
className={
typeof className === 'string' ? 'nui-DialogTrigger ' + className : 'nui-DialogTrigger'
}
/>
);
}
export function DialogBackdrop(
props: React.ComponentProps<typeof BaseDialog.Backdrop>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Backdrop
{...rest}
className={
typeof className === 'string' ? 'nui-DialogBackdrop ' + className : 'nui-DialogBackdrop'
}
/>
);
}
export function DialogPopup(
props: React.ComponentProps<typeof BaseDialog.Popup>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Popup
{...rest}
className={typeof className === 'string' ? 'nui-DialogPopup ' + className : 'nui-DialogPopup'}
/>
);
}
export function DialogTitle(
props: React.ComponentProps<typeof BaseDialog.Title>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Title
{...rest}
className={typeof className === 'string' ? 'nui-DialogTitle ' + className : 'nui-DialogTitle'}
/>
);
}
export function DialogDescription(
props: React.ComponentProps<typeof BaseDialog.Description>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Description
{...rest}
className={
typeof className === 'string'
? 'nui-DialogDescription ' + className
: 'nui-DialogDescription'
}
/>
);
}
export function DialogClose(
props: React.ComponentProps<typeof BaseDialog.Close>,
): React.ReactElement {
const { className, ...rest } = props;
return (
<BaseDialog.Close
{...rest}
className={typeof className === 'string' ? 'nui-DialogClose ' + className : 'nui-DialogClose'}
/>
);
}