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.