Shared utils

Cross-platform utilities, hooks, formatters, and dictionaries

Installation

Shared utils install automatically with components. To install separately:

1 lines
pnpm add @wandercom/design-system-shared

Classes

cn

Class name merging with Tailwind CSS conflict resolution. Built on clsx and tailwind-merge, pre-configured with design system class groups (text sizes, shadows, etc.).

13 lines
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
      )}
    />
  );
}

createCn

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.

9 lines
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.

Hooks

Import hooks individually:

1 lines
import { useMediaQuery } from '@wandercom/design-system-shared/hooks/use-media-query';

useMediaQuery

Subscribes to a CSS media query. Returns boolean | undefined (undefined during SSR).

1 lines
const isWide = useMediaQuery('(min-width: 1024px)');

useIsDesktop / useIsMobile

Convenience wrappers around useMediaQuery with a 744px breakpoint.

4 lines
import { useIsDesktop, useIsMobile } from '@wandercom/design-system-shared/hooks/use-media-query';

const isDesktop = useIsDesktop(); // min-width: 744px
const isMobile = useIsMobile();   // max-width: 743px

useContainerQuery

Subscribes 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).

7 lines
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.

useIsContainerDesktop / useIsContainerMobile

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.

9 lines
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 65rem

usePrefersReducedMotion

Detects the user's reduced motion preference. Returns boolean | undefined during SSR.

3 lines
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.

useStaggeredAnimation

Returns per-item CSS styles for staggered entrance animations, respecting reduced motion.

11 lines
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>
))}

useResizeObserver

Returns true while the window or an optional referenced HTML element is actively being resized. Debounced (default 150ms).

5 lines
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 ms

useScrolled

Returns true when the window scroll position exceeds a threshold (default 0).

3 lines
import { useScrolled } from '@wandercom/design-system-shared/hooks/use-scrolled';

const isScrolled = useScrolled(50); // 50px threshold

useHeaderThemeSync

Detects when a header element overlaps dark theme sections and returns the appropriate theme. Uses intersection detection with mutation, scroll, and resize observers.

8 lines
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" | undefined

useActiveIndex

Tracks 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.

18 lines
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".

useCharacterLimit

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.

8 lines
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 maxLength attribute 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.

useThrottledValue

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.

10 lines
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 (default true) emits the first value synchronously so empty → first-token feels instant.
  • trailing (default true) guarantees the final value lands even if it arrives inside the throttle window.

useClaudeStream and useOpenAIStream use this internally for the same reason.

useClaudeStream

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.

19 lines
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_delta events. Surface tool-use, thinking, and usage events via onEvent.
  • content is throttled via useThrottledValue. Set throttleMs: 0 to disable.

useOpenAIStream

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.

17 lines
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) and choices[].delta.content (Chat Completions). Other events go through onEvent.
  • response.error events populate error and stop the stream.

Web-specific hooks

These import from @wandercom/design-system-web/hooks/* rather than @wandercom/design-system-shared/* because they depend on the DOM.

useControllableState

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.

15 lines
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>
  );
}

useStickToBottom

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.

18 lines
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 ResizeObserver on the content element re-pins to the bottom when content grows while sticky. This is what makes streaming feel anchored.

Formatters

Import formatters individually:

1 lines
import { formatDateRange } from '@wandercom/design-system-shared/formatters/dates';

Dates

12 lines
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.

Counts

11 lines
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"

Money

3 lines
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:

14 lines
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"

Calendar

Shared calendar range utilities and vacation rental domain logic for consistent date selection, availability validation, and stay constraint computation across Calendar and PropertyCalendar implementations.

7 lines
import {
  buildAvailabilityMap,
  getMaxCheckoutDate,
  rangeHasUnavailableDay,
  validateCheckinDate,
  validateCheckoutDate,
} from '@wandercom/design-system-shared/calendar';

Types

AvailabilityDay — a single day's availability data from the property management system.

10 lines
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.

4 lines
interface DateRange {
  start: Date | null;
  end: Date | null;
}

StayRequirements — constraints on minimum/maximum nights and allowed check-in/check-out days.

6 lines
interface StayRequirements {
  minNights?: number;
  maxNights?: number;
  checkInDays?: string[];   // e.g. ["Saturday"]
  checkOutDays?: string[];
}

BookingRule — a booking rule that constrains date selection behavior.

12 lines
interface BookingRule {
  type:
    | 'min-nights'
    | 'max-nights'
    | 'check-in-day'
    | 'check-out-day'
    | 'advance-booking'
    | 'gap-nights'
    | 'same-day-turnover'
    | 'quota-exhausted';
  message: string;
}

Date utilities

parseLocalDate(dateStr) — parses an ISO date string (YYYY-MM-DD) into a local Date with no timezone shift.

3 lines
import { parseLocalDate } from '@wandercom/design-system-shared/calendar';

parseLocalDate('2026-06-15'); // Date for June 15, 2026, local time

getWeekdayName(date) — returns the full weekday name in US English.

1 lines
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.

4 lines
import { buildAvailabilityMap } from '@wandercom/design-system-shared/calendar';

const availabilityMap = buildAvailabilityMap(availabilityDays);
const day = availabilityMap.get('2026-06-15');

Range selection

isDateInRange(date, start, end) — returns true when date falls strictly between start and end (exclusive of both endpoints).

3 lines
import { isDateInRange } from '@wandercom/design-system-shared/calendar';

isDateInRange(midDate, checkIn, checkOut); // true if between

getDateRangeState(date, selected, minDate?) — derives all range state flags for a given date against the current selection. Useful for driving calendar day cell styles.

10 lines
import { getDateRangeState } from '@wandercom/design-system-shared/calendar';

const {
  isRangeStart,
  isRangeEnd,
  isInRange,
  isSingleSelected,
  isRangeEndpoint,
  isDisabled,
} = getDateRangeState(date, { from: checkIn, to: checkOut }, minDate);

Availability validation

validateCheckinDate(date, availabilityMap, stayRequirements?) — validates a proposed check-in date. Returns an error string, or null when valid.

6 lines
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.

9 lines
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.

5 lines
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.

5 lines
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.

5 lines
import { rangeHasUnavailableDay } from '@wandercom/design-system-shared/calendar';

if (rangeHasUnavailableDay(availability, checkIn, checkOut)) {
  // selected range overlaps an unavailable day
}

Responsive variants

Type-safe utilities for building components with responsive CVA variants. Solves Tailwind's tree-shaking limitation by requiring all responsive variants defined upfront.

21 lines
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:

1 lines
<Component size={{ base: 'sm', md: 'lg' }} />

BreakpointValue<T> supports breakpoints: base, sm, md, lg, xl, 2xl, 3xl, 4xl.

Thumbhash

Progressive image loading with Thumbhash placeholders. Tiny (~20-30 byte) image previews that display while full images load.

7 lines
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.

Dictionaries

Toast dictionary

Pre-defined toast message templates with support for dynamic values. Provides consistent toast copy across applications.

8 lines
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? }.

Countries

Curated country lists (ISO 3166-1 alpha-2 codes) for use with PhoneInput, CountrySelect, and other country-aware components.

2 lines
import { ALL_COUNTRIES } from '@wandercom/design-system-shared/countries';
import { STRIPE_CONNECT_COUNTRIES } from '@wandercom/design-system-shared/countries';
ExportCountDescription
ALL_COUNTRIES235All ISO-2 country codes
STRIPE_PAYMENT_COUNTRIES50Stripe Tax-supported countries
STRIPE_SUPPORTED_COUNTRIES49Countries where Stripe is available for businesses
STRIPE_CONNECT_COUNTRIES46Stripe 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:

4 lines
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)} />

Stripe theme

Composable appearance config for Stripe Elements that mirrors DS token values — input sizing, border radii, typography, and color for both light and dark modes.

18 lines
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>
Loading example...

createStripeAppearance

Returns a complete Stripe Appearance object. Accepts either a boolean for the default Wander palette, or a StripeColors object for custom theming.

9 lines
// 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' })

resolveStripeColors

Returns a StripeColors object for a given mode. Use this to read resolved color values or to build a custom palette.

7 lines
import {
  resolveStripeColors,
  createStripeAppearance,
  type StripeColors,
} from '@wandercom/design-system-shared/stripe-theme';

const colors: StripeColors = resolveStripeColors(isDark);

StripeColors fields:

FieldDescription
colorBackgroundPage/card background
colorPrimaryPrimary text and interactive color
colorSurfaceSecondary surface (Stripe colorSuccess)
inputBackgroundInput field background
colorPlaceholderPlaceholder text
borderSecondaryDefault border
borderHoverHover border
borderSelectedFocus/selected border
colorDangerError color

createStripeVariables / createStripeRules

Composable primitives for building partial appearance objects. Useful when you need to merge variables or rules selectively.

19 lines
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' },
  },
};

stripeRadius

Radius tokens used across the Stripe appearance.

6 lines
import { stripeRadius } from '@wandercom/design-system-shared/stripe-theme';

stripeRadius.input   // "8px"
stripeRadius.global  // "16px"
stripeRadius.tab     // "4rem"
stripeRadius.button  // "9999px"

stripeLayout

Pre-configured PaymentElement layout using the accordion style.

3 lines
import { stripeLayout } from '@wandercom/design-system-shared/stripe-theme';

<PaymentElement options={{ layout: stripeLayout }} />

Font helpers

10 lines
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')