Component Style Guide

When building components for the Functn Component Library, we follow a set of consistent guidelines.

Composition over props

Our components are often made up of many elements - An accordion has a container, a container per item, a header and content element for each item etc.

We prefer to compose these components from smaller atomic component, rather than having one component that does all the rendering:

// Do not do this:
<Accordion
  items={[
    { title: 'Item 1', content: 'Content 1' },
    { title: 'Item 2', content: 'Content 2' },
  ]}
/>
 
// Do this instead:
<AccordionGroup>
  <Accordion>
    <AccordionHeader>Item 1</AccordionHeader>
    <AccordionPanel>Content 1</AccordionPanel>
  </Accordion>
  <Accordion>
    <AccordionHeader>Item 2</AccordionHeader>
    <AccordionPanel>Content 2</AccordionPanel>
  </Accordion>
</AccordionGroup>

Why is this important?

Sometimes this makes it harder to design components, it often requires context providers at the top level. But it results in more flexible, reusable components.

Extending HTML element props

If a component renders a <button> then it should accept all the props that a <button> accepts. Those props should be passed through to the <button> element.

The purpose of this is to allow the component to be used in a more flexible way. For example, to take any aria-* attributs a <button> accepts, or any data-* attributes a <button> accepts.

This is true for all HTML elements.

const Button = ({ children, ...props }: ComponentProps<'button'>) => {
  return <button {...props}>{children}</button>;
};

The className prop

Our components should define default styles, but always allow styles to be overridden with a className prop. The standard pattern we use is to use our cn() library to merge the default styles with the className prop.

const Button = ({
  children,
  className,
  ...props
}: ComponentProps<'button'>) => {
  return (
    <button
      className={cn('flex flex-row gap-2 rounded-lg', className)}
      {...props}
    >
      {children}
    </button>
  );
};

Styling with data-* attributes

Some components have internal state - e.g. an <Accordion /> component has an internal state for whether it is expanded or not.

The elements that make up the component should expose that state with data-* attributes.

These data attributes should be used to style the component conditionally.

// Don't do this:
<section
  className={cn(
    isExpanded ? 'bg-primary-50' : 'bg-primary-100',
    className,
  )}
/>
 
// Do this instead:
<section
  data-state={isExpanded ? 'open' : 'closed'}
  className={cn(
    'data-[state=open]:bg-primary-50 data-[state=closed]:bg-primary-100',
    className,
  )}
/>

The reason for this is because it makes it possible to style the component's different states from the outside. The parent can pass in Tailwind classes that target the data-* attributes.

<AccordionItem className="data-[state=open]:bg-gray-900 data-[state=closed]:bg-gray-600">

Group classnames

All components should add Tailwind group classNames to their root element, and any elements that have data-* attributes. This is so that we can target the various states from child elements.

const AccordionItem = ({ children, ...props }: ComponentProps<'section'>) => {
  const { isExpanded } = useContext(DisclosureStateContext);
 
  return (
    <section
      data-state={isExpanded ? 'open' : 'closed'}
      className={cn(
        'group/accordion-item',
        'data-[state=open]:bg-primary-50 data-[state=closed]:bg-primary-100',
        className,
      )}
      {...props}
    >
      {children}
    </section>
  );
};

3rd party libraries

We can't (and shouldn't!) build everything from scratch. But adding 3rd party dependencies should be done with care.

Components should try to use react-aria-components where possible.

That's not always possible, for example a Carousel component requires a specific Carousel library. But these cases should be the exception to the rule.