Skip to content

Managing CSS Focus In A (React) Component Based Architecture

Published: at 03:22 PM

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>;
This is an unscoped custom property for brevity. Create scoped ones in 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(),
    });
  });
};
Although we've manually merged props here, using a package like 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.