Focus is a fun one to manage. It has to be consistent across your application, account for light and dark backgrounds, be themable, progressively enhanced, and, depending on the whims of your designer, might need to trigger certain pseudo-classes because -design things-.
Honestly, this can be solved in a million ways and the best solution completely depends on your architecture.
Make it a component
In a component based architecture, styling is done by composing components.
<Box width="200px" height="100px" bg="backgroundPrimary">
<Stack gap="5">
<Heading level="4">Heading</Heading>
<Text>This is a paragraph</Text>
<Button>Click me</Button>
</Stack>
</Box>
Focus is also styling, so we want make it composable. Let’s introduce the <FocusManager />
component and integrate it into various interactive elements:
const Button = ({ children, ...props }: ButtonProps) => {
return (
<FocusManager>
<button {...props}>{children}</button>
</FocusManager>
);
};
const InputText = (props: InputProps) => {
return (
<FocusManager>
<input type="text" {...props} />
</FocusManager>
);
};
Creating the FocusManager
component
The FocusManager
component should generate :focus
styles and pass them onto its children.
Styling the focus and generating a className
We’ll be using vanilla-extract/recipes
. We’ll go into more detail as to why later on.
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { vars } from '@theme/tokens';
export const focusManager = recipe({
base: {
vars: {
'--outlineColor': vars.outlines.outlineOnLight,
},
':focus': {
outline: 'var(--outlineColor)',
},
'@supports': {
':focus-visible': {
// Reset the regular focus when focus-visible is supported
':focus': {
outline: 'none',
},
':focus-visible': {
outline: 'var(--outlineColor)',
},
},
},
},
variants: {
tone: {
/** Sets colors that contrast well with a light background */
onLight: {
vars: {
'--outlineColor': vars.outlines.outlineOnLight,
},
},
/** Sets colors that contrast well with a dark background */
onDark: {
vars: {
'--outlineColor': vars.outlines.outlineOnDark,
},
},
},
},
defaultVariants: {
tone: 'onLight',
},
});
export type FocusVariants = RecipeVariants<typeof focusManager>;
vanilla-extract
with createVar
Creating the wrapper component that passes down the styles
Now that we have created our styles, we’ll create the React component. I’ll be using a Wrapper Component pattern for this. It’s a bit more obfuscated for maintainers than, for example, a render prop. But consuming it has a nice developer experience, is consistent within a component based architecture, and it’s a great way for component library authors to add constraints.
Alternatively, you could use a render props pattern.
Let’s create a component that clones its children
and passes the className
that represent the styles we created earlier.
import React from 'react';
import * as styles from './styles.css.ts';
type FocusProps = {
children: React.ReactNode;
} & styles.FocusVariants;
const FocusManager = ({ children, tone }: FocusProps) => {
const focusClassName = style.focusManager({ tone });
return React.Children.map(children, child => {
return React.cloneElement(child, {
...child.props,
className: `${child.props.className ?? ''} ${focusClassName}`.trim(),
});
});
};
merge-props
is a great way to merge props as well.
Creating a contract between composable elements
By leveraging the power of css-in-ts and variant based styling, we can create a contract between the “tones” of the interactive element and the focus. For example, let’s take an extremely simplified input component:
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { vars } from '@theme/tokens';
export const input = recipe({
base: {
background: vars.input.backgroundColor,
},
variants: {
tone: {
onLight: {
border: `1px solid ${vars.input.borderColorOnLight}`,
},
onDark: {
border: `1px solid ${vars.input.borderColorOnDark}`,
},
},
},
defaultVariants: {
tone: 'onLight',
},
});
export type InputVariants = RecipeVariants<typeof input>;
And the component InputText
which will implement the styles.
import * as styles from './styles.css.ts';
type InputProps = {
className?: string;
} & styles.InputVariants;
const InputText = ({ tone, className = '', ...props }: InputProps) => {
const inputClassName = styles.input({ tone });
const combinedClassName = `${className} ${inputClassName}`.trim();
return (
<FocusManager tone={tone}>
<input type="text" className={combinedClassName} {...props} />
</FocusManager>
);
};
If tone
from the input allows other values than the FocusManager
, Typescript gives us an error. This is essentially a contract, and promotes consistency in both visual identity and domain language.