- Accordion
- Avatar
- Badge
- Breadcrumb
- Button
- Calendar
- ChatContainer
- ChatInput
- ChatMessage
- ChatMultiChoiceQuestion
- ChatMultiOptionQuestion
- ChatThinking
- Checkbox
- Combobox
- Container
- CurrencyInput
- DistributionSlider
- Drawer
- Dropdown
- FilePicker
- Grid
- Heading
- Image
- Input
- InputGroup
- Label
- Logo
- MapPin
- Markdown
- Modal
- NativeSelect
- NumberInput
- OptionSlider
- OtpInput
- PhoneInput
- Popover
- Progress
- PropertyCalendar
- RadioGroup
- RadioGroupCards
- ResponsiveModal
- ScrollArea
- SearchBar
- SearchBarFallback
- SearchInput
- Select
- Separator
- Spinner
- Switch
- Table
- Tabs
- Text
- Textarea
- TimePicker
- Toast
- Toggle
- ToggleCard
- ToggleGroup
- Toolbar
- Tooltip
Shared utils
Cross-platform utilities, hooks, formatters, and dictionaries
Shared utils install automatically with components. To install separately:
pnpm add @wandercom/design-system-sharedClass name merging with Tailwind CSS conflict resolution. Built on clsx and tailwind-merge, pre-configured with design system class groups (text sizes, shadows, etc.).
import { cn } from '@wandercom/design-system-shared/classes';
function Card({ className, elevated }) {
return (
<div
className={cn(
'rounded-lg border p-4',
elevated && 'shadow-modal',
className
)}
/>
);
}Factory function to create a custom cn with extended Tailwind class groups. Use this when your project has custom Tailwind classes beyond the design system defaults.
import { createCn } from '@wandercom/design-system-shared/classes';
export const cn = createCn({
extend: {
classGroups: {
'font-size': ['text-marketing-hero', 'text-marketing-subtitle'],
},
},
});createCn executes once at module level and returns a function identical to cn but aware of your additional classes.
Import hooks individually:
import { useMediaQuery } from '@wandercom/design-system-shared/hooks/use-media-query';Subscribes to a CSS media query. Returns boolean | undefined (undefined during SSR).
const isWide = useMediaQuery('(min-width: 1024px)');Convenience wrappers around useMediaQuery with a 744px breakpoint.
import { useIsDesktop, useIsMobile } from '@wandercom/design-system-shared/hooks/use-media-query';
const isDesktop = useIsDesktop(); // min-width: 744px
const isMobile = useIsMobile(); // max-width: 743pxSubscribes to the width of a referenced HTML element. Supports min-width and max-width queries in px or rem, and returns boolean | undefined (undefined until the element is available and during SSR).
import { useRef } from 'react';
import { useContainerQuery } from '@wandercom/design-system-shared/hooks/use-container-query';
const containerRef = useRef<HTMLDivElement>(null);
const isWide = useContainerQuery(containerRef, '(min-width: 65rem)');
return <div ref={containerRef}>{isWide ? <WideLayout /> : <NarrowLayout />}</div>;Unsupported query syntax returns false.
Convenience wrappers around useContainerQuery with the 65rem (1040px) container breakpoint used by container-driven components. useIsContainerMobile is the complement of the desktop query, so every measured width resolves to exactly one layout.
import { useRef } from 'react';
import {
useIsContainerDesktop,
useIsContainerMobile,
} from '@wandercom/design-system-shared/hooks/use-container-query';
const containerRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsContainerDesktop(containerRef); // min-width: 65rem
const isMobile = useIsContainerMobile(containerRef); // below 65remDetects the user's reduced motion preference. Returns boolean | undefined during SSR.
import { usePrefersReducedMotion } from '@wandercom/design-system-shared/hooks/use-reduced-motion';
const prefersReduced = usePrefersReducedMotion();usePrefersReducedMotionSafe is also exported and defaults to false during SSR instead of undefined.
Returns per-item CSS styles for staggered entrance animations, respecting reduced motion.
import { useStaggeredAnimation } from '@wandercom/design-system-shared/hooks/use-staggered-animation';
const { getItemStyle } = useStaggeredAnimation({
isActive: true,
baseDelayMs: 80,
stepMs: 60,
});
{items.map((item, i) => (
<div key={item.id} style={getItemStyle(i)}>{item.name}</div>
))}Returns true while the window or an optional referenced HTML element is actively being resized. Debounced (default 150ms).
import { useRef } from 'react';
import { useResizeObserver } from '@wandercom/design-system-shared/hooks/use-resize-observer';
const containerRef = useRef<HTMLDivElement>(null);
const isResizing = useResizeObserver(200, containerRef); // custom debounce msReturns true when the window scroll position exceeds a threshold (default 0).
import { useScrolled } from '@wandercom/design-system-shared/hooks/use-scrolled';
const isScrolled = useScrolled(50); // 50px thresholdDetects when a header element overlaps dark theme sections and returns the appropriate theme. Uses intersection detection with mutation, scroll, and resize observers.
import { useHeaderThemeSync } from '@wandercom/design-system-shared/hooks/use-header-theme-sync';
const headerRef = useRef<HTMLElement>(null);
const theme = useHeaderThemeSync(headerRef, {
enabled: true,
selector: '[data-theme="dark"]:not([data-theme-scope="local"])',
});
// Returns "dark" | "light" | undefinedTracks the active index in a list of selectable rows so the highlight on the last hovered or focused item persists when the pointer leaves. Used by the chat multi-choice and multi-option primitives.
import { useActiveIndex } from '@wandercom/design-system-shared/hooks/use-active-index';
const { isActive, getItemProps } = useActiveIndex({
initialIndex: firstEnabledIndex,
disabled: value !== null,
});
options.map((option, index) => (
<button
{...getItemProps(index)}
className={cn(
'rounded-lg',
(isActive(index) || option.value === value) && 'bg-surface-secondary'
)}
>
{option.label}
</button>
));The hook has no opinion about visual treatment — it just resolves which index should currently render as active. clear() distinguishes "never interacted" from "explicitly suppressed".
Hard character cap on inputs and textareas. Any input — typing, paste, drop, IME composition — that would push the value past maxLength is rejected via a native beforeinput listener.
import { useCharacterLimit } from '@wandercom/design-system-shared/hooks/use-character-limit';
const { count, maxLength, inputProps } = useCharacterLimit({
maxLength: 40,
});
<input {...inputProps} />
<span>{count}/{maxLength}</span>- Rejects rather than truncates. The HTML
maxLengthattribute silently truncates pasted content; this hook rejects the whole insertion so the user's clipboard isn't quietly cut short. - Supports controlled (
value+onValueChange) and uncontrolled (defaultValue) usage.
Coalesces rapid value updates into at most one update per intervalMs. The latest value is always emitted; intermediate values are dropped. Built for token streams where re-rendering on every change is wasteful but the final value must always land.
import { useThrottledValue } from '@wandercom/design-system-shared/hooks/use-throttled-value';
function StreamedMarkdown({ content }: { content: string }) {
const throttled = useThrottledValue(content, {
intervalMs: 80,
leading: true,
trailing: true,
});
return <Markdown content={throttled} />;
}leading(defaulttrue) emits the first value synchronously so empty → first-token feels instant.trailing(defaulttrue) guarantees the final value lands even if it arrives inside the throttle window.
useClaudeStream and useOpenAIStream use this internally for the same reason.
Consumes an AsyncIterable<ClaudeStreamEvent> from the Anthropic Messages streaming API and exposes the accumulated assistant text. The most common source is client.messages.stream(...) from @anthropic-ai/sdk.
import { useClaudeStream } from '@wandercom/design-system-shared/hooks/use-claude-stream';
const stream = useMemo(
() => client.messages.stream({
model: 'claude-opus-4-7',
messages,
max_tokens: 1024,
}),
[messages]
);
const { content, isStreaming, error, stop } = useClaudeStream(stream, {
throttleMs: 50,
onEvent: (event) => {
// surface tool_use / thinking deltas here
},
});
return <Markdown content={content} />;- Only reads
text_deltaevents. Surface tool-use, thinking, and usage events viaonEvent. contentis throttled viauseThrottledValue. SetthrottleMs: 0to disable.
Same shape as useClaudeStream, but consumes an OpenAI stream. Supports both the Responses API (client.responses.stream(...)) and Chat Completions (client.chat.completions.create({ stream: true })) — the hook discriminates internally so you can pass either source without conversion.
import { useOpenAIStream } from '@wandercom/design-system-shared/hooks/use-openai-stream';
// Responses API
const stream = useMemo(
() => client.responses.stream({ model: 'gpt-4o', input }),
[input]
);
const { content, isStreaming, error, stop } = useOpenAIStream(stream);
// Chat Completions
const stream = useMemo(
() => client.chat.completions.create({ model: 'gpt-4o', messages, stream: true }),
[messages]
);
const { content } = useOpenAIStream(stream);
return <Markdown content={content} />;- Reads
response.output_text.delta(Responses API) andchoices[].delta.content(Chat Completions). Other events go throughonEvent. response.errorevents populateerrorand stop the stream.
These import from @wandercom/design-system-web/hooks/* rather than @wandercom/design-system-shared/* because they depend on the DOM.
The standard "is this controlled or uncontrolled?" primitive. Resolves to value when defined, otherwise tracks internal state seeded by defaultValue. Always forwards to onChange so callers can observe uncontrolled transitions too. Used internally by inputs, toggles, and other form primitives.
import { useControllableState } from '@wandercom/design-system-web/hooks/use-controllable-state';
function Toggle({ value, defaultValue = false, onChange }: ToggleProps) {
const [checked, setChecked] = useControllableState({
value,
defaultValue,
onChange,
});
return (
<button onClick={() => setChecked(!checked)}>
{checked ? 'On' : 'Off'}
</button>
);
}Pins a scrollable region to the bottom while the user is engaged, suspends auto-stick when they scroll up, and signals "new messages" when content grows while they're scrolled away. Powers ChatContainer.
import { useStickToBottom } from '@wandercom/design-system-web/hooks/use-stick-to-bottom';
const {
scrollRef,
contentRef,
isAtBottom,
hasNewMessages,
scrollToBottom,
} = useStickToBottom({ threshold: 96, behavior: 'smooth' });
return (
<div ref={scrollRef} className="overflow-y-auto">
<div ref={contentRef}>{children}</div>
{hasNewMessages && (
<button onClick={() => scrollToBottom()}>New messages</button>
)}
</div>
);threshold(default 96px) is the slack zone that still counts as "at bottom" — generous enough that wheel nudges and smooth-scroll overshoot don't flip the state.- A
ResizeObserveron the content element re-pins to the bottom when content grows while sticky. This is what makes streaming feel anchored.
Import formatters individually:
import { formatDateRange } from '@wandercom/design-system-shared/formatters/dates';import {
formatDate,
formatDateRange,
getShortMonthName,
getMonthName,
DATE_FORMATS,
} from '@wandercom/design-system-shared/formatters/dates';
formatDate(new Date(), 'MMM d, yyyy'); // "Mar 9, 2026"
formatDateRange(checkIn, checkOut); // "Mar 15 - 20" or "Mar 15 - Apr 2"
getShortMonthName(2); // "Mar"
getMonthName(new Date()); // "March"DATE_FORMATS provides standard format strings: short, medium, long, dayOfWeek, dayOfWeekLong, monthYear, monthYearShort.
import { formatCount, formatCounts } from '@wandercom/design-system-shared/formatters/counts';
formatCount(1, { singular: 'guest' }); // "1 guest"
formatCount(3, { singular: 'guest' }); // "3 guests"
formatCount(0, { singular: 'guest', fallback: 'No guests' }); // "No guests"
formatCounts([
{ count: 2, singular: 'guest' },
{ count: 1, singular: 'pet' },
]);
// "2 guests, 1 pet"import { formatMoney } from '@wandercom/design-system-shared/formatters/money';
formatMoney({ currency: 'USD', fractional: 15000 }); // "$150.00"The fractional value is in cents (divided by 100 for display). Uses Intl.NumberFormat for locale-aware formatting.
Formatters for search bar UI labels:
import {
formatSearchBarDatesLabel,
formatSearchBarGuestsLabel,
formatFlexibleDatesLabel,
} from '@wandercom/design-system-shared/formatters/search-bar';
formatSearchBarGuestsLabel({ adults: 2, children: 1, pets: 1 });
// "3 guests, 1 pet"
formatSearchBarGuestsLabel({});
// "Who"
formatFlexibleDatesLabel('weekend', ['jan', 'feb']);
// "Weekend in Jan, Feb"Shared calendar range utilities and vacation rental domain logic for consistent date selection, availability validation, and stay constraint computation across Calendar and PropertyCalendar implementations.
import {
buildAvailabilityMap,
getMaxCheckoutDate,
rangeHasUnavailableDay,
validateCheckinDate,
validateCheckoutDate,
} from '@wandercom/design-system-shared/calendar';AvailabilityDay — a single day's availability data from the property management system.
interface AvailabilityDay {
date: string; // ISO date string (YYYY-MM-DD)
canCheckIn: boolean;
canCheckOut: boolean;
isBlocked?: boolean;
minNights?: number;
maxNights?: number;
nightPrice?: number;
status: 'available' | 'booked' | 'maintenance' | 'blocked';
}DateRange — a check-in / check-out date pair, where either or both may be unset.
interface DateRange {
start: Date | null;
end: Date | null;
}StayRequirements — constraints on minimum/maximum nights and allowed check-in/check-out days.
interface StayRequirements {
minNights?: number;
maxNights?: number;
checkInDays?: string[]; // e.g. ["Saturday"]
checkOutDays?: string[];
}BookingRule — a booking rule that constrains date selection behavior.
interface BookingRule {
type:
| 'min-nights'
| 'max-nights'
| 'check-in-day'
| 'check-out-day'
| 'advance-booking'
| 'gap-nights'
| 'same-day-turnover'
| 'quota-exhausted';
message: string;
}parseLocalDate(dateStr) — parses an ISO date string (YYYY-MM-DD) into a local Date with no timezone shift.
import { parseLocalDate } from '@wandercom/design-system-shared/calendar';
parseLocalDate('2026-06-15'); // Date for June 15, 2026, local timegetWeekdayName(date) — returns the full weekday name in US English.
getWeekdayName(new Date('2026-06-20')); // "Saturday"buildAvailabilityMap(availability) — builds a fast-lookup Map from an availability array, keyed by ISO date string. Pass this map into validation functions instead of re-iterating the array.
import { buildAvailabilityMap } from '@wandercom/design-system-shared/calendar';
const availabilityMap = buildAvailabilityMap(availabilityDays);
const day = availabilityMap.get('2026-06-15');isDateInRange(date, start, end) — returns true when date falls strictly between start and end (exclusive of both endpoints).
import { isDateInRange } from '@wandercom/design-system-shared/calendar';
isDateInRange(midDate, checkIn, checkOut); // true if betweengetDateRangeState(date, selected, minDate?) — derives all range state flags for a given date against the current selection. Useful for driving calendar day cell styles.
import { getDateRangeState } from '@wandercom/design-system-shared/calendar';
const {
isRangeStart,
isRangeEnd,
isInRange,
isSingleSelected,
isRangeEndpoint,
isDisabled,
} = getDateRangeState(date, { from: checkIn, to: checkOut }, minDate);validateCheckinDate(date, availabilityMap, stayRequirements?) — validates a proposed check-in date. Returns an error string, or null when valid.
import { validateCheckinDate } from '@wandercom/design-system-shared/calendar';
const error = validateCheckinDate(date, availabilityMap, {
checkInDays: ['Saturday'],
});
// null | "Unavailable" | "Check-in unavailable"validateCheckoutDate(date, checkinDate, availabilityMap, stayRequirements?, rangeHasBlocked?) — validates a proposed check-out date against availability, stay requirements, and an optional range-blocked check. Returns an error string, or null when valid.
import { validateCheckoutDate } from '@wandercom/design-system-shared/calendar';
const error = validateCheckoutDate(
checkoutDate,
checkinDate,
availabilityMap,
{ minNights: 3, maxNights: 14 },
);
// null | "Unavailable" | "Min 3 nights" | "Max 14 nights" | ...getMaxCheckoutDate(checkinDate, availabilityMap, stayRequirements?) — computes the latest date a guest may check out. Returns the minimum of checkinDate + maxNights and the first blocked/booked day after check-in. Returns null when there is no computable upper bound.
import { getMaxCheckoutDate } from '@wandercom/design-system-shared/calendar';
const maxCheckout = getMaxCheckoutDate(checkinDate, availabilityMap, {
maxNights: 14,
});pathHasBlockedDay(availabilityMap, from, to) — returns true when the path between from and to (exclusive of both endpoints) contains a blocked, booked, or maintenance day. Use this to prevent range selections that span unavailable nights.
import { pathHasBlockedDay } from '@wandercom/design-system-shared/calendar';
if (pathHasBlockedDay(availabilityMap, checkIn, checkOut)) {
// range crosses an unavailable night
}rangeHasUnavailableDay(availability, start, end) — returns true when any day in the range is neither check-in nor check-out eligible. O(n) linear scan intended for one-shot validation (e.g. detecting stale selections after availability loads), not for per-cell rendering.
import { rangeHasUnavailableDay } from '@wandercom/design-system-shared/calendar';
if (rangeHasUnavailableDay(availability, checkIn, checkOut)) {
// selected range overlaps an unavailable day
}Type-safe utilities for building components with responsive CVA variants. Solves Tailwind's tree-shaking limitation by requiring all responsive variants defined upfront.
import {
expandResponsiveVariants,
type VariantPropsResponsive,
type BreakpointValue,
} from '@wandercom/design-system-shared/responsive';
const variants = cva('', {
variants: {
size: { sm: '...', md: '...', lg: '...' },
sizeMd: { sm: 'md:...', md: 'md:...', lg: 'md:...' },
},
});
const RESPONSIVE_KEYS = ['size'] as const;
type Props = VariantPropsResponsive<typeof variants, typeof RESPONSIVE_KEYS>;
function Component(props: Props) {
return (
<div className={expandResponsiveVariants(variants, RESPONSIVE_KEYS, props)} />
);
}Enables responsive variant syntax:
<Component size={{ base: 'sm', md: 'lg' }} />BreakpointValue<T> supports breakpoints: base, sm, md, lg, xl, 2xl, 3xl, 4xl.
Progressive image loading with Thumbhash placeholders. Tiny (~20-30 byte) image previews that display while full images load.
import {
thumbHashToDataURL,
getThumbHashDimensions,
} from '@wandercom/design-system-shared/thumbhash';
const dataUrl = thumbHashToDataURL(thumbHashString);
const { width, height } = getThumbHashDimensions(thumbHashString);Both functions return null on error and work in browser and SSR contexts.
Pre-defined toast message templates with support for dynamic values. Provides consistent toast copy across applications.
import { createToastDictionary } from '@wandercom/design-system-shared/dictionaries/toasts';
const toasts = createToastDictionary({
BookingSaved: {
label: 'Booking saved',
description: 'Your booking has been saved.',
},
});createToastDictionary merges your custom toasts with the shared defaults (CopiedToClipboard, ChangedTheme, ErrorGeneral, ErrorActionFailed, ErrorInvalidInput, ErrorInputRequired, RequestReceived). Labels and descriptions can be strings or functions receiving { count?, date?, value? }.
Curated country lists (ISO 3166-1 alpha-2 codes) for use with PhoneInput, CountrySelect, and other country-aware components.
import { ALL_COUNTRIES } from '@wandercom/design-system-shared/countries';
import { STRIPE_CONNECT_COUNTRIES } from '@wandercom/design-system-shared/countries';| Export | Count | Description |
|---|---|---|
ALL_COUNTRIES | 235 | All ISO-2 country codes |
STRIPE_PAYMENT_COUNTRIES | 50 | Stripe Tax-supported countries |
STRIPE_SUPPORTED_COUNTRIES | 49 | Countries where Stripe is available for businesses |
STRIPE_CONNECT_COUNTRIES | 46 | Stripe Connect countries (used by WanderOS) |
Each list is a const array of lowercase ISO-2 strings. The CountryCode type extracts the union of all valid codes.
To use with PhoneInput, convert codes to CountryData[] via filterCountries:
import { STRIPE_CONNECT_COUNTRIES } from '@wandercom/design-system-shared/countries';
import { filterCountries } from '@wandercom/design-system-web/ui/phone-input';
<PhoneInput countries={filterCountries(STRIPE_CONNECT_COUNTRIES)} />Composable appearance config for Stripe Elements that mirrors DS token values — input sizing, border radii, typography, and color for both light and dark modes.
import {
createStripeAppearance,
createStripeGoogleFonts,
stripeLayout,
} from '@wandercom/design-system-shared/stripe-theme';
<Elements
stripe={stripePromise}
options={{
mode: 'payment',
currency: 'usd',
amount: 10_000,
fonts: createStripeGoogleFonts('Instrument Sans'),
appearance: createStripeAppearance(isDark),
}}
>
<PaymentElement options={{ layout: stripeLayout }} />
</Elements>Returns a complete Stripe Appearance object. Accepts either a boolean for the default Wander palette, or a StripeColors object for custom theming.
// Default palette
createStripeAppearance(isDark)
// Custom palette — override individual color values
const colors = { ...resolveStripeColors(false), colorDanger: '#c53030' };
createStripeAppearance(colors)
// Appearance-level overrides merged on top
createStripeAppearance(isDark, { labels: 'floating' })Returns a StripeColors object for a given mode. Use this to read resolved color values or to build a custom palette.
import {
resolveStripeColors,
createStripeAppearance,
type StripeColors,
} from '@wandercom/design-system-shared/stripe-theme';
const colors: StripeColors = resolveStripeColors(isDark);StripeColors fields:
| Field | Description |
|---|---|
colorBackground | Page/card background |
colorPrimary | Primary text and interactive color |
colorSurface | Secondary surface (Stripe colorSuccess) |
inputBackground | Input field background |
colorPlaceholder | Placeholder text |
borderSecondary | Default border |
borderHover | Hover border |
borderSelected | Focus/selected border |
colorDanger | Error color |
Composable primitives for building partial appearance objects. Useful when you need to merge variables or rules selectively.
import {
resolveStripeColors,
createStripeVariables,
createStripeRules,
} from '@wandercom/design-system-shared/stripe-theme';
const colors = resolveStripeColors(isDark);
const appearance = {
theme: isDark ? 'night' : 'flat',
variables: {
...createStripeVariables(colors),
fontSizeBase: '14px',
},
rules: {
...createStripeRules(colors),
'.Label': { fontSize: '14px', marginBottom: '8px' },
},
};Radius tokens used across the Stripe appearance.
import { stripeRadius } from '@wandercom/design-system-shared/stripe-theme';
stripeRadius.input // "8px"
stripeRadius.global // "16px"
stripeRadius.tab // "4rem"
stripeRadius.button // "9999px"Pre-configured PaymentElement layout using the accordion style.
import { stripeLayout } from '@wandercom/design-system-shared/stripe-theme';
<PaymentElement options={{ layout: stripeLayout }} />import {
createStripeGoogleFonts,
createStripeLocalFonts,
} from '@wandercom/design-system-shared/stripe-theme';
// Load from Google Fonts
createStripeGoogleFonts('Instrument Sans')
// Load a self-hosted font
createStripeLocalFonts('/fonts/InstrumentSans.woff2', 'Instrument Sans')- Emails - Email templates package
- Design tokens - Token-based styling
- Installation - Set up the design system