Theming playground
This page allows you to experience Balance with different themes. You can edit all Balance theming tokens and save them as your own custom theme. You can switch between themes and move around the docs site to see what Balance would look like under a different theme.
Usage
The playground is always rendered with the default theme and all its tokens prepoulated so you can start making changes immediately.
For example, scroll down to the ACTIONS
token group and change the active
color a red shade.
Now scroll back up and hit the Save theme
button. It should show a popup explaining that you can't override the default theme and that you need to pick a different name for your theme so let's do that.
Go to the Theme name
input and change it to something like red buttons
and hit save. You'll notice that it saves the theme and immediately switches to it.
Notice the red buttons. You can switch back to the default theme by selecting Reckon web: Light
from the Current theme
menu.
For now switch back to the red buttons
theme and visit the buttons package. Notice all the buttons there are red as well, this is because your selected theme has been applied to the entire site.
Navigate back to the theming playground page and hit refresh. You'll notice everything has switched back to the default theme but worry not, we have saved your theme in local storage so it persists through page refreshes. You can simply select it from the Current theme
menu and you're back where you left off.
Lastly, you can delete a theme easily when you're done with it. Simply clicking the Remove theme
button will remove your custom theme and revert the site back to the default theme. Note that you cannot delete the default theme.
const defaultTheme = { colors: { background: '#ffffff', text: '#20262D', primary: '#007AFF', brand: '#F84184', accent: '#3C3391', danger: '#E30613', warning: '#FFB027', success: '#009754', info: '#0091EA', decorative: [ '#BDE7FF', '#B1FAE0', '#FFF0B3', '#FFB7B2', '#FFB1D8', '#CBC1F4', '#AFD5FF', ], variant: [ '#BDE7FF', '#B1FAE0', '#FFF0B3', '#FFB7B2', '#FFB1D8', '#CBC1F4', '#AFD5FF', ], }, borderWidth: { standard: 1, large: 2, }, breakpoints: { small: 576, medium: 768, large: 992, xlarge: 1200, }, contentWidth: { xsmall: 480, small: 720, medium: 1024, large: 1200, xlarge: 1440, }, elevation: { card: 100, dropdown: 200, sticky: 300, modal: 400, popover: 500, toast: 600, }, shadow: { xsmall: '0px 1px 2px rgba(0, 0, 0, 0.2)', small: '0px 2px 4px rgba(0, 0, 0, 0.2)', medium: '0px 2px 8px rgba(0, 0, 0, 0.2)', large: '0px 4px 16px rgba(0, 0, 0, 0.2)', xlarge: '-8px 8px 32px rgba(0, 0, 0, 0.2)', }, name: 'Reckon web: Light', palette: { decorative: [ '#BDE7FF', '#B1FAE0', '#FFF0B3', '#FFB7B2', '#FFB1D8', '#CBC1F4', '#AFD5FF', ], variant: [ '#BDE7FF', '#B1FAE0', '#FFF0B3', '#FFB7B2', '#FFB1D8', '#CBC1F4', '#AFD5FF', ], actions: { passive: '#585c62', active: '#007AFF', critical: '#E30613', }, global: { accent: '#3C3391', border: '#e4e5e6', focusRing: 'rgba(0,122,255,0.2)', loading: '#007AFF', }, border: { standard: '#e4e5e6', muted: '#f2f2f2', accent: '#d0cee5', active: '#c2dfff', cautious: '#ffeccb', critical: '#f8c3c6', informative: '#c2e5fa', positive: '#c2e6d6', }, background: { base: '#ffffff', muted: '#f8f8f9', dim: '#f2f2f2', shade: '#ebebec', dialog: '#ffffff', accent: '#e0deed', selectableHover: '#fbfbfb', selectablePressed: '#f6f6f7', selectableSelected: '#f5faff', selectableSelectedHover: '#ebf4ff', active: '#d6eaff', cautious: '#fff2dc', critical: '#fbd7d9', informative: '#d6edfc', positive: '#d6eee4', activeMuted: '#cce4ff', cautiousMuted: '#ffefd4', criticalMuted: '#f9cdd0', informativeMuted: '#cce9fb', positiveMuted: '#cceadd', }, text: { base: '#20262D', muted: '#585c62', dim: '#909396', link: '#007AFF', linkHover: '#3689ff', linkVisited: '#3C3391', accent: '#3C3391', active: '#007AFF', cautious: '#FFB027', critical: '#E30613', informative: '#0091EA', positive: '#009754', }, formInput: { background: '#f2f2f2', backgroundHovered: '#f2f2f2', backgroundFocused: '#f2f2f2', backgroundDisabled: '#f8f8f9', backgroundInvalid: '#fbd7d9', border: '#f2f2f2', borderHovered: '#e4e5e6', borderFocused: '#007AFF', borderDisabled: '#f8f8f9', borderInvalid: '#fbd7d9', text: '#20262D', textDisabled: '#585c62', textPlaceholder: '#909396', textInvalid: '#E30613', }, formControl: { background: '#ffffff', backgroundDisabled: '#e4e5e6', border: '#e4e5e6', interaction: '#007AFF', foregroundChecked: '#ffffff', foregroundDisabled: '#909396', }, segmentedControl: { track: '#f2f2f2', divider: 'rgba(32,38,45,0.16)', backgroundSelected: '#ffffff', text: '#585c62', textFocused: '#20262D', textPressed: '#20262D', textSelected: '#20262D', }, toggle: { track: '#e4e5e6', trackChecked: '#007AFF', trackDisabled: '#f2f2f2', trackDisabledChecked: '#909396', handle: 'white', }, listItem: { backgroundFocused: '#edeeee', backgroundPressed: '#e4e5e6', backgroundSelected: '#e4e5e6', divider: '#e4e5e6', text: '#20262D', textFocused: '#20262D', textPressed: '#20262D', textSelected: '#20262D', }, table: { rowBackgroundCautious: '#fffaf2', rowBackgroundCritical: '#fdf0f1', rowBackgroundPositive: '#f0f9f5', }, menuItem: { backgroundFocused: '#f2f2f2', backgroundPressed: '#e4e5e6', backgroundSelected: '#f0f7ff', divider: '#e4e5e6', text: '#20262D', textDisabled: '#909396', textFocused: '#20262D', textPressed: '#20262D', textSelected: '#20262D', }, tooltip: { background: '#ffffff', text: '#20262D', }, button: { active: { background: '#007AFF', backgroundFocused: '#2381ff', backgroundPressed: '#006ff2', }, critical: { background: '#E30613', backgroundFocused: '#ed1c1a', backgroundPressed: '#d50007', }, passive: { background: '#585c62', backgroundFocused: '#5f6369', backgroundPressed: '#4e5258', }, }, actionButton: { background: '#ffffff', backgroundFocused: '#f2f2f2', backgroundPressed: '#e4e5e6', backgroundSelected: '#e4e5e6', border: '#e4e5e6', borderFocused: '#dbdcdd', borderPressed: '#c9cbcd', borderSelected: '#c9cbcd', text: '#585c62', textFocused: '#20262D', textPressed: '#20262D', textSelected: '#20262D', }, interactive: { base: { background: '#ffffff', hover: '#f2f2f2', active: '#e4e5e6', }, }, }, radii: { none: 0, xsmall: 4, small: 6, medium: 8, large: 12, full: 9999, }, sizes: { xsmall: { fontSizeDisplay: '0.75rem', fontSizeText: '0.75rem', borderRadius: 4, boxSize: 24, gap: 4, paddingX: 8, paddingY: 2, }, small: { fontSizeDisplay: '0.75rem', fontSizeText: '0.875rem', borderRadius: 6, boxSize: 32, gap: 8, paddingX: 12, paddingY: 4, }, medium: { fontSizeDisplay: '1.125rem', fontSizeText: '1rem', borderRadius: 8, boxSize: 40, gap: 8, paddingX: 16, paddingY: 8, }, }, sizing: { xxsmall: 16, xsmall: 24, small: 32, base: 40, medium: 48, large: 56, xlarge: 72, }, spacing: { none: 0, xxsmall: 2, xsmall: 4, small: 8, medium: 12, large: 16, xlarge: 24, xxlarge: 32, }, typography: { fontFamily: { monospace: '"SFMono-Medium","SF Mono","Segoe UI Mono","Roboto Mono","Ubuntu Mono", Menlo, Consolas, Courier, monospace', body: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', heading: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', }, fontSize: { xsmall: '0.75rem', small: '0.875rem', medium: '1rem', large: '1.125rem', xlarge: '1.25rem', xxlarge: '1.5rem', xxxlarge: '1.875rem', xxxxlarge: '2.25rem', xxxxxlarge: '3rem', xxxxxxlarge: '4rem', }, fontWeight: { regular: 400, medium: 500, semibold: 600, bold: 700, heavy: 800, }, leading: { tighter: 1, tight: 1.2, base: 1.4, loose: 1.6, looser: 1.8, }, },};const THEME_LOCAL_STORAGE_KEY = '__BALANCE_DOCS_DEMO_THEMES';const getThemesFromLS = () => { const themes = localStorage.getItem('__BALANCE_DOCS_DEMO_THEMES'); if (!themes?.length) return {}; try { const json = JSON.parse(themes); return json; } catch (error) { return {}; }};const setThemeInLS = (theme) => { const themes = getThemesFromLS(); themes[theme.name] = theme; localStorage.setItem(THEME_LOCAL_STORAGE_KEY, JSON.stringify(themes));};const removeThemeInLS = (theme) => { const themes = getThemesFromLS(); delete themes[theme.name]; localStorage.setItem(THEME_LOCAL_STORAGE_KEY, JSON.stringify(themes));};const makeCumulativeKey = (cumulativeKey, newKey) => { return `${cumulativeKey.length ? `${cumulativeKey}.` : ''}${newKey}`;};const TokenGroup = ({ label, children,}: { label: string, children: React.ReactNode,}) => { return ( <Fieldset legend={label.toUpperCase()}> <Stack gap="medium" paddingLeft="xxlarge" paddingY="small"> {children} </Stack> <Box paddingY="medium"> <Divider direction="horizontal" /> </Box> </Fieldset> );};const TokenField = ({ label, register, formValue,}: { label: string, register: any, formValue: string,}) => { // There's probably a better way to detect color? If not, I'll have to integrate metadata into the theme schema. const isColorField = formValue?.toString().startsWith('#') || formValue?.toString().startsWith('rgb'); return ( <Field label={label}> {isColorField ? ( <input type="color" {...register()} /> ) : ( <TextInput {...register()} /> )} </Field> );};const renderThemeForm = (formValue, cumulativeKey, register) => { return ( <Stack gap="small"> {Object.keys(formValue)?.map((key, i) => { if (['elevation', 'name'].includes(key)) return null; if (typeof formValue[key] === 'object') return ( <Box paddingY="small"> <Fieldset legend={key.toUpperCase()}> <Box paddingLeft="xxlarge" paddingY="small"> {renderThemeForm( formValue[key], makeCumulativeKey(cumulativeKey, key), register )} </Box> {i < Object.keys(formValue).length - 1 && ( <Box paddingY="medium"> <Divider direction="horizontal" /> </Box> )} </Fieldset> </Box> ); const finalKey = makeCumulativeKey(cumulativeKey, key); return ( <TokenField label={key} register={() => register(finalKey)} formValue={formValue[key]} /> ); })} </Stack> );};const ThemeForm = () => { const createThemeOptions = () => { return [defaultTheme, ...Object.values(getThemesFromLS())].map((t) => ({ label: t.name, value: t, })); }; const { addToast } = useToasts(); const theme = useRawTheme(); const [showDefaultThemeAlert, setShowDefaultThemeAlert] = useState(false); const [confirmThemeDeletion, setConfirmThemeDeletion] = useState(false); const [themeOptions, setThemeOptions] = useState(createThemeOptions()); const [selectedTheme, setSelectedTheme] = useState({ label: theme.name, value: theme, }); const { register, watch, handleSubmit, getValues, reset, } = ReactHookForm_useForm({ defaultValues: theme, }); const isDefaultThemeSelected = selectedTheme.label === defaultTheme.name; const triggerLabel = 'Current theme' + (selectedTheme ? `: ${selectedTheme.label}` : ''); const sendThemeUpdateEvent = (theme?: object) => { // This sends an event to the theme provider in BAL docs website layout which overrides theme window.dispatchEvent( new CustomEvent('__BALANCE_DOCS_THEME_UPDATED', { detail: { theme }, }) ); }; const onThemeChange = (option) => { setSelectedTheme(option); const theme = themeOptions.find((theme) => theme.label === option.label) .value; reset(theme); sendThemeUpdateEvent(theme); }; const handleSaveTheme = () => { const customTheme = getValues(); delete customTheme.borderWidth; delete customTheme.breakpoints; delete customTheme.contentWidth; delete customTheme.elevation; delete customTheme.radii; delete customTheme.sizes; delete customTheme.spacing; delete customTheme.sizing; if (customTheme.name === defaultTheme.name) { setShowDefaultThemeAlert(true); return; } setThemeInLS(customTheme); addToast({ title: 'Theme saved', message: 'You should be able to try your new theme by selecting it from the Theme dropdown.', }); setThemeOptions(createThemeOptions()); setSelectedTheme({ label: customTheme.name, value: customTheme, }); reset(customTheme); sendThemeUpdateEvent(customTheme); }; const handleRemoveTheme = () => { setConfirmThemeDeletion(true); }; const removeTheme = () => { const theme = themeOptions.find( (theme) => theme.label === selectedTheme.label ).value; removeThemeInLS(theme); setThemeOptions(createThemeOptions()); reset(defaultTheme); setSelectedTheme( themeOptions.find((theme) => theme.label === defaultTheme.name) ); sendThemeUpdateEvent(defaultTheme); setConfirmThemeDeletion(false); addToast({ title: 'Theme removed', message: `We've switched back to the default theme`, }); }; return ( <React.Fragment> <Stack gap="xlarge"> <SelectMenu trigger={triggerLabel} options={themeOptions} value={selectedTheme} onChange={onThemeChange} itemToValue={(option) => option.label} /> <Stack gap="small"> <TokenField label="Theme name" register={() => register('name')} /> <Button label="Save theme" onClick={handleSaveTheme} /> {!isDefaultThemeSelected && ( <Button label="Remove theme" variant="outline" onClick={handleRemoveTheme} block /> )} </Stack> <Text weight="heavy"> Token heirarchy is represented by indentation. </Text> {renderThemeForm(defaultTheme, '', register)} </Stack> <AlertDialog actions={{ confirm: { label: 'Ok', action: () => setShowDefaultThemeAlert(false), }, }} isOpen={showDefaultThemeAlert} title="Cannot override default theme" > <Stack gap="medium"> <Text> Your theme cannot be named `{defaultTheme.name}` as it is the default theme. </Text> <Text>Pick a different name for your theme.</Text> </Stack> </AlertDialog> <AlertDialog actions={{ cancel: { label: 'No', action: () => setConfirmThemeDeletion(false), }, confirm: { label: 'Delete', action: removeTheme }, }} isOpen={confirmThemeDeletion} title={`Delete theme`} > <Stack gap="medium"> <Text>Are you sure you want to the {selectedTheme.label} theme</Text> </Stack> </AlertDialog> </React.Fragment> );};return <ThemeForm />;