Component Patterns
Architectural patterns used throughout @so.boss/gloss-ui. Understanding these patterns helps you consume components correctly and leverage TypeScript for compile-time safety.
1. Discriminated Unions
Several Gloss components use a discriminated union on a variant prop to narrow the set of allowed props at compile time. MetricCard is the primary example: it defines 9 variants, each with its own required and optional props, all sharing a common base interface.
TypeScript narrows the type automatically based on the variant value. If you pass props that belong to a different variant, the compiler rejects it before you ever run the code.
// The discriminated union type
type MetricCardProps =
| StandardVariant // variant: 'standard'
| SparklineVariant // variant: 'sparkline'
| BarsVariant // variant: 'bars'
| DonutVariant // variant: 'donut'
| HighlightVariant // variant: 'highlight'
| ArrowVariant // variant: 'arrow'
| ObjectArrowVariant // variant: 'object-arrow'
| VersusVariant // variant: 'versus'
| SegmentedVariant; // variant: 'segmented'
// Each variant extends a shared base
interface SparklineVariant extends BaseMetricCardProps {
variant: 'sparkline';
sparklineData: number[]; // Required only for this variant
accentColor?: string;
}
// Usage: TypeScript knows sparklineData is required here
<MetricCard variant="sparkline" label="Orders" sparklineData={[42, 38, 51, 47, 55]} />
// Usage: TypeScript knows value is the primary prop here
<MetricCard variant="standard" label="Revenue" value={128400} />
// Compile error: sparklineData is not valid on 'standard' variant
// <MetricCard variant="standard" label="Revenue" sparklineData={[1,2,3]} />Key benefit
Invalid prop combinations are caught at compile time. You cannot accidentally pass sparkline data to a donut card or bar data to a standard card. The compiler acts as a guardrail, eliminating an entire class of runtime bugs.
2. Controlled Components
All Gloss components are fully controlled. They do not maintain internal state for values, selections, or visibility. The consuming application owns all state and passes it down via props. When user interaction occurs, the component fires a callback — it never updates its own state.
This means there is no useState for values inside library components. Animation state, hover effects, and layout measurement hooks are the only internal state allowed — these are implementation details that do not affect the data contract.
import { TabbedDataView } from '@so.boss/gloss-ui';
function MyDashboard() {
// YOU own the state
const [activeTab, setActiveTab] = useState('orders');
const [tabs, setTabs] = useState<TabConfig[]>(initialTabs);
return (
<TabbedDataView
tabs={tabs}
activeTabId={activeTab}
onTabChange={setActiveTab} // Callback: component tells you what happened
onTabClose={(id) => setTabs(t => t.filter(tab => tab.id !== id))}
renderTabContent={(tab) => <DataTable tab={tab} />}
/>
);
}Key benefit
Predictable data flow. Your app is the single source of truth for all state. Components become pure renderers that are easy to test, debug, and reason about. You can persist state anywhere — localStorage, server, URL params — without the library caring how.
3. Render Props
Layout and container components use render props to delegate content rendering to the consuming application. The library handles layout, interaction, and behavior — you provide the actual content.
This pattern appears in MetricCardGrid (renderCard, renderEmptySlot) and TabbedDataView (renderTabContent). The component calls your render function with the relevant data, and you return the JSX to display.
import { MetricCardGrid, MetricCard } from '@so.boss/gloss-ui';
function Dashboard() {
return (
<MetricCardGrid
cards={cardData}
cardOrder={order}
layout={gridLayout}
onReorder={setOrder}
onPageChange={setPage}
// You decide how each card renders
renderCard={(card) => (
<MetricCard {...card.props} />
)}
// You decide what empty slots look like
renderEmptySlot={(index) => (
<div className="empty-slot">
<button onClick={() => addCard(index)}>+ Add Card</button>
</div>
)}
/>
);
}Key benefit
The library never needs to know about your specific card variants, data shapes, or rendering requirements. You get layout, pagination, and drag-and-drop behavior for free while retaining full control over what appears in each slot.
4. Slot Props
Slot props accept a ReactNode that the component renders in a designated position. Unlike render props (which are functions), slot props are pre-rendered elements. This pattern is used for dependency inversion — the component declares a visual slot, and you inject any component you want.
The canonical example is ChartControlBar's datePickerSlot. The control bar needs a date picker in its top-right corner, but it does not import or know about HybridDatePicker. Instead, you pass it in.
import { ChartControlBar } from '@so.boss/gloss-ui';
import { HybridDatePicker } from '@so.boss/gloss-ui';
function ChartControls() {
return (
<ChartControlBar
vizState={vizState}
onTypeChange={setType}
onQuantChange={setQuant}
// Inject any date picker into the slot
datePickerSlot={
<HybridDatePicker
value={dateRange}
onChange={setDateRange}
presets={presets}
/>
}
/>
);
}
// Or inject a completely different component:
<ChartControlBar
{...props}
datePickerSlot={<SimpleDateInput value={date} onChange={setDate} />}
/>Key benefit
Components stay decoupled from each other. ChartControlBar works with any date picker — or no date picker at all. This makes components independently testable and allows you to swap implementations without modifying library code.
5. Co-located Types, Fixtures, and Hooks
Every component family follows a consistent directory structure. Types, fixtures, and hooks live alongside the component they belong to — not in shared directories. This communicates ownership and prevents inappropriate reuse of implementation details.
MetricCard/
types.ts -- Type contracts (interfaces, unions)
MetricCard.tsx -- Main component
CardShell.tsx -- Sub-component (shared wrapper)
TrendIndicator.tsx -- Sub-component
GutterActions.tsx -- Sub-component
useSlideOutReveal.ts -- Co-located hook (used only by GutterActions)
utils/
formatValue.ts -- Pure utility function
formatTrend.ts -- Pure utility function
visualizations/
SparklineBg.tsx -- SVG visualization
BarsBg.tsx -- SVG visualization
DonutChart.tsx -- SVG visualization
fixtures/
allVariants.ts -- Sample data for all 9 variants
states.ts -- Loading, error, stale state samples
index.ts -- Family barrel (re-exports components + types)Key benefit
Everything related to a component family is in one place. Types are exported from the family barrel alongside components. Fixtures provide demo data without external dependencies. Hooks that are implementation details stay co-located rather than polluting a shared hooks directory.
6. BEM SCSS with gloss- Prefix
All CSS classes follow the BEM naming convention with a gloss- prefix. This ensures component styles never collide with application styles, even without CSS Modules or CSS-in-JS.
SCSS files use design tokens from @so.boss/gloss-styles via @use "tokens" as *. Variable references like $color-gray-900 and $space-4 keep values consistent across the library.
// Component SCSS follows BEM with gloss- prefix
.gloss-metric-card {
position: relative;
overflow: hidden;
// Element
&__header {
display: flex;
align-items: center;
gap: $space-2;
}
// Modifier (variant)
&--sparkline {
.gloss-metric-card__bg {
opacity: 0.6;
}
}
// Modifier (state)
&--stale {
.gloss-metric-card__value {
filter: blur(4px);
transition: filter 300ms ease;
}
}
}Key benefit
No CSS Modules, no styled-components, no runtime CSS-in-JS overhead. The gloss- prefix guarantees isolation. BEM makes the relationship between blocks, elements, and modifiers explicit and predictable.
7. CSS Variable Fallbacks
Components reference design tokens via var(--token-name, fallback-value). The fallback ensures components render correctly even if the consumer has not loaded the token CSS file. This enables gradual adoption and standalone usage.
The dashboard bridge file (_dashboard-bridge.scss) maps CSS custom properties to SCSS token values. Consumers can override variables at the :root level or use the bridge for standard theming.
// Inside a component's SCSS
.gloss-card-shell {
background: var(--color-surface-primary, #ffffff);
color: var(--color-text-primary, #374151);
box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));
border-radius: var(--radius-lg, 12px);
}
// Consumer can override at :root level
:root {
--color-surface-primary: #1a1a2e;
--color-text-primary: #e0e0e0;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3);
}
// Or scope overrides to a container
.dark-theme {
--color-surface-primary: #1a1a2e;
--color-text-primary: #e0e0e0;
}Key benefit
Components work out of the box without any CSS setup. The fallback values provide a sensible default theme. When you are ready to customize, override CSS variables at the scope you need — globally, per-section, or per-component instance.
8. Container/Presenter Split
Gloss components are the presenters. They handle rendering, layout, and visual interaction (hover, animation, scroll). They do not fetch data, read from context, access localStorage, or manage application state.
Your application provides the containers — components that connect data sources (API calls, context, stores) to the presenter's props. This separation keeps the library portable and testable.
// Mental model:
// Container (your app) --> props --> Presenter (@so.boss/gloss-ui)
// Your container: owns state, fetches data, handles side effects
function MetricCardContainer({ cardId }: { cardId: string }) {
const { data, isLoading } = useMetricsQuery(cardId);
const [isExpanded, setIsExpanded] = useState(false);
return (
<CardShell
colSpan={isExpanded ? 2 : 1}
onExpand={() => setIsExpanded(true)}
onShrink={() => setIsExpanded(false)}
>
<MetricCard
variant="sparkline"
label={data?.label ?? ''}
value={data?.value}
sparklineData={data?.trend ?? []}
isLoading={isLoading}
/>
</CardShell>
);
}
// What @so.boss/gloss-ui NEVER does:
// - useContext(AppContext) -- no context consumption
// - localStorage.getItem(...) -- no storage access
// - fetch('/api/...') -- no data fetching
// - useRouter() -- no routingKey benefit
Library components are environment-agnostic. They work in any React app, any test runner, any Storybook setup — no providers, no mocking, no configuration. Your containers are the only place where app-specific dependencies appear.