Browse docs
Browse docs
Every @dashforge/tw component exposes two escape hatches for visual
customization plus a preset extension API for global token overrides.
This guide explains when to use which, with concrete examples.
By the end of the page you'll know how to:
sx)slotProps)<TextField> uses@dashforge/tw exposes two distinct override props because they
solve two distinct problems:
| Prop | Targets | Type | Conflict resolution |
|---|---|---|---|
sx | The outer wrapper of the component | string (utility classes) | tailwind-merge last-wins via cn() — sx always beats variant classes on conflict |
slotProps | Internal sub-elements (label, helperText, input, …) | Typed object, per-slot | Per-slot className merge — same tailwind-merge semantics applied inside each slot |
Why sx and not className? className carries no semantic
weight — a reader of your code sees className="bg-red-500" and
has to read the surrounding context to understand whether the
class merges, overrides, or duplicates the component's own classes.
sx carries a guarantee: whatever you pass wins on conflict via
tailwind-merge, and the prop name itself signals "this is the
style escape hatch". As a result, @dashforge/tw components
intentionally omit className from their public props (it's
removed via Omit<…, 'className'> in TypeScript) — there is one
path, and the path is sx.
Why slotProps and not descendant CSS selectors? Selectors
like .my-textfield input { … } are fragile — they break when the
component's internal DOM changes between versions. slotProps
gives you a typed, explicit, per-slot surface that's part of the
public API. The slot list is documented per-component and never
changes silently.
Need to style the outer wrapper? → sx
(margin, max-width, position, shadow, container queries, …)
Need to style an internal element? → slotProps.<slot>
(label color, helperText size, input padding, …)
Need to inject content into a slot? → slotProps.prefix/suffix (TextField only)
(currency symbol, unit suffix, status icon)
Need both? → use both, they're orthogonal.sximport { Button, TextField, Box } from '@dashforge/tw';
// Margin + shadow on the Button itself
<Button variant="solid" sx="ml-4 shadow-xl">
Save
</Button>
// Max-width container on a TextField
<TextField name="email" label="Email" sx="max-w-md" />
// Responsive utility on a foundation primitive
<Box variant="solid" color="primary" sx="md:w-1/2 lg:w-1/3">
Hero
</Box>sx accepts any Tailwind utility string. The classes are appended
after the variant classes so they win on conflict — see
Example 3 below.
slotPropsimport { TextField } from '@dashforge/tw';
<TextField
name="email"
label="Email address"
helperText="We never share your email"
slotProps={{
label: { className: 'uppercase tracking-wide text-xs' },
helperText: { className: 'italic text-sky-600' },
input: { className: 'font-mono' },
}}
/>The slot list per component is documented in the corresponding
TypeScript types (e.g. TextFieldSlotProps, CheckboxSlotProps).
For most form-control components the standard slots are:
root — outermost wrapperlabel — visible label above (or beside) the controlrequiredMark — the * asterisk after a required labelinputWrapper — wrapper around the input (carries the focus ring)input — the <input> (or <select>, <textarea>) itselfhelperText — helper line below the controlerrorText — error line below the control (replaces helperText when an error is present)Component-specific slots: Autocomplete adds popover, listBox,
listItem, chipsList, chip, chipRemove; NumberField adds
stepper, stepperButton; OTPField adds slotsRow, slot,
slotChar; RadioGroup adds optionList, option,
indicator, optionLabel.
This is the property that makes sx worth its name:
// variant + color emit `bg-primary-500` via tailwind-variants.
// sx overrides with `bg-red-500`. tailwind-merge resolves in favor of sx.
<Button variant="solid" color="primary" sx="bg-red-500">
Danger override
</Button>The rule is tested in Button.test.tsx (line 88-90):
"sx overrides a conflicting variant class via tailwind-merge". It's
not a happy accident — the public contract guarantees that whatever
you pass via sx wins.
This means you can safely use sx as the local-override path
without worrying about specificity wars or CSS source-order
fragility.
sx + slotProps togetherThe two props are orthogonal — you can combine them freely:
import { TextField } from '@dashforge/tw';
<TextField
name="amount"
label="Importo"
sx="max-w-xs"
slotProps={{
label: { className: 'uppercase tracking-wide' },
input: { className: 'tabular-nums' },
prefix: { children: '€' },
suffix: { children: 'EUR' },
}}
/>sx="max-w-xs" caps the outer wrapper width.slotProps.label makes the label uppercase + extended tracking.slotProps.input switches the input to tabular numerals.slotProps.prefix.children injects an inline € adornment.slotProps.suffix.children injects an inline EUR adornment.The result is a fully-styled currency input without writing a single line of CSS or forking the component.
| Component | Standard slots | Extra slots |
|---|---|---|
TextField | root label requiredMark inputWrapper input helperText errorText | prefix suffix (accept children) |
Button | — (atomic component, no slots) | — |
Autocomplete | root label requiredMark inputWrapper input helperText errorText | trigger clearButton popover listBox listItem emptyState chipsList chip chipRemove |
Switch | root label helperText errorText | control thumb |
LeftNav | root footer | brand list item itemLink itemActive itemIcon itemLabel itemBadge group groupHeader groupChildren collapseToggle |
Provider-based components (ConfirmDialog, Snackbar) use a
different top-level slot shape because they render via portals
rather than a single root element:
ConfirmDialog — backdrop dialog title body actions confirmButton cancelButtonSnackbar — container item icon message action closeButtonAtomic components (Button, Box, Typography, AspectRatio,
VisuallyHidden) have no slotProps — there are no sub-elements
to address. Use sx for these.
For global token overrides (brand color, spacing scale, font
family) you don't reach for sx on each component — you extend
the dashforgePreset() once and every component picks up the
change automatically.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
import { dashforgePreset } from '@dashforge/tw-theme';
export default {
content: ['./src/**/*.{ts,tsx}'],
presets: [
dashforgePreset({
// Brand color — replaces the default `primary` palette
colors: {
primary: {
50: '#eef9ff',
100: '#dbf2ff',
200: '#beeaff',
300: '#92ddff',
400: '#5ec8ff',
500: '#3cb5ff', // your brand-500
600: '#1a93eb',
700: '#1576c0',
800: '#1762a0',
900: '#185183',
},
},
// Add a brand-only intent (extends, doesn't replace)
extendColors: {
brand: {
500: '#ff5722',
600: '#e64a19',
700: '#bf360c',
},
},
// Override the spacing scale
spacing: {
// Re-define the canonical token values
// (default values shown — change as needed)
},
}),
],
} satisfies Config;Variant API consumers (<Button color="primary">,
<Box color="brand">) automatically pick up the new palette
because the variant recipes reference the tokens by name, not by
literal value. The preset's CSS-variable layer handles light/dark
auto-inversion the same way for your custom palette as it does for
the defaults.
If you want <Button color="brand"> to be a valid type and produce
the right classes, you need to augment the variant via the
tailwind-variants augmentation API:
// src/dashforge-augment.d.ts
import '@dashforge/tw';
declare module '@dashforge/tw' {
interface ButtonColorAugment {
brand: true;
}
}After this declaration, TypeScript and the variant recipe both
accept color="brand" on <Button>. Same pattern for any
component that exposes a color axis.
dashforgePreset({
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
})<Typography variant="body1"> and every text-rendering component
inherit the new sans-serif stack. Tabular components (NumberField
input, OTPField slots) automatically pick up the mono stack via
their internal font-mono utility.
The most powerful customization path is the one that doesn't touch styling at all: building your own components on top of the Dashforge bridge so they get form integration, RBAC, error gating, and visibility predicates for free.
The bridge contract is exposed by three packages:
@dashforge/forms — useDashFieldMeta (granular per-field subscription)@dashforge/ui-core — DashFormContext, useEngineVisibility@dashforge/rbac — useAccessStateHere's a minimal custom field that consumes all three:
// src/components/PhoneInput.tsx
import { useContext } from 'react';
import { tv } from 'tailwind-variants';
import { cn } from '@dashforge/tw';
import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
import { useDashFieldMeta } from '@dashforge/forms';
import { useAccessState } from '@dashforge/tw/hooks/useAccessState';
import type { DashFormBridge, Engine } from '@dashforge/ui-core';
import type { AccessRequirement } from '@dashforge/rbac';
const phoneInputVariants = tv({
slots: {
root: 'flex flex-col gap-1',
label: 'text-sm font-medium text-neutral-700',
input: [
'rounded-md border border-neutral-300 px-3 py-2',
'focus:outline-none focus:ring-2 focus:ring-primary-500',
'data-[error=true]:border-danger-500',
],
},
});
export interface PhoneInputProps {
name: string;
label?: string;
rules?: unknown;
visibleWhen?: (engine: Engine) => boolean;
access?: AccessRequirement;
sx?: string;
}
export function PhoneInput({
name,
label,
rules,
visibleWhen,
access,
sx,
}: PhoneInputProps) {
const v = phoneInputVariants();
const bridge = useContext(DashFormContext) as DashFormBridge | null;
const engine = bridge?.engine;
// Granular subscription — re-renders ONLY when this field's state changes
const meta = useDashFieldMeta(name);
// Visibility predicate — component renders null when false
const isVisible = useEngineVisibility(engine, visibleWhen);
// RBAC — derives disabled/readonly/hidden from the user's permissions
const accessState = useAccessState(access);
if (!isVisible || accessState.hidden) return null;
const disabled = accessState.disabled || meta.disabled;
const value = (bridge?.engine.getValue(name) as string) ?? '';
return (
<div className={cn(v.root(), sx)}>
{label && <label className={v.label()}>{label}</label>}
<input
type="tel"
inputMode="tel"
className={v.input()}
data-error={meta.error ? 'true' : 'false'}
value={value}
disabled={disabled}
onChange={(e) => bridge?.setValue(name, e.target.value)}
onBlur={() => bridge?.markTouched(name)}
/>
{meta.error && meta.touched && (
<span className="text-sm text-danger-600">{meta.error.message}</span>
)}
</div>
);
}Drop this into a <DashFormProvider> and it behaves exactly like
the built-in <TextField>:
<DashFormProvider engine={engine}>
<PhoneInput name="phone" label="Phone" rules={{ required: true }} />
</DashFormProvider>You get:
onSubmit payloadrules enforced; errors surface in meta.errortouched or submitCount > 0access requirement gates hidden / disabled /
readonlyvisibleWhen(engine) returning false
unmounts the fielduseDashFieldMeta re-renders this
component only when this field's state changes; sibling fields'
changes don't rippleEverything you wrote was UI. Everything else came from the bridge.
When building your own field, the following hooks must be called unconditionally at the top of the render (React rules of hooks):
useContext(DashFormContext) — get the bridgeuseDashFieldMeta(name) — subscribe to field stateuseEngineVisibility(engine, visibleWhen) — visibility checkuseAccessState(access) — RBAC resolutionThen read accessState.hidden / isVisible to decide whether to
return null. The hooks must be called every render, even when the
component eventually returns null — calling them conditionally
breaks StrictMode and dev-mode subscription guarantees.
sx does NOT dosx is a plain string of utility classes. If
you need conditional logic, compose with cn():
import { cn } from '@dashforge/tw';
<Button sx={cn('ml-4', isPrimary && 'shadow-xl', isWide && 'w-full')}>
…
</Button>sx="pointer-events-auto" to
a button disabled by RBAC will not re-enable it — the underlying
disabled attribute remains. Use access explicitly to control
RBAC.sx always lands on the root wrapper. To style
internal elements use slotProps.slotProps does NOT dochildren on most slots. Only prefix / suffix on
TextField accept children. The other slots accept
className only.slotProps.<slot> only
accepts className today. If you need data-* for testing or
third-party integration, use sx + data-*=… on the parent or
wrap the component.libs/dashforge/tw/src/components/*/[component].types.tslibs/dashforge/tw/src/components/Button/Button.test.tsx lines 82-92libs/dashforge/tw/PARITY.md — see "Intentional deltas" section for the sx/slotProps design rationalelibs/dashforge/tw/A11Y.md — accessibility surface that sx / slotProps must not break