Back to home

how i write extensive react components (ui elements)

Posted on thursday, 31st Oct, 2024

with the high adoption of tools such as next-js, shadcn-ui, tailwindcss and framer-motion, there are so many coding patterns coming in picture to follow. some focus more on the accessibility part of things and some are more on the design and aesthetic end. in such cases, following a single coding pattern becomes tough, while making sure that your code is accessible, aesthetically pleasing with all the CSS and animation tinkering.

according to my learnings and the components i have been building, i have been following the shared coding pattern for writing react components. this uses basic typescript utilities for making components with better DX and making sure they are native.

for this explanation, let's take an example of a button component, with some initial set of variants and sizes.

the button component will contain all the native-button properties. following we will add the listed properties:

  • variant - primary, secondary, ghost etc.
  • size - sm, md, lg
  • rightIcon - prop to render an icon in the right slot
  • leftIcon - prop to render an icon in the right slot
  • stretch - to cover the full width inside a container, relatively.
  • isLoading - to show loader icon on API calls & dummy loading states.

🧮 play around with the component

🌱 starting with a basic interface for component props

here i am using the native type for button components as a extension to the component props

export interface ButtonProps extends React.ButtonHTMLAtrributes<HTMLButtonElement> {}

tsx

to the same interface, we can add custom properties using individual types

export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps extends React.ButtonHTMLAtrributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
  rightIcon?: TablerIcon;
  leftIcon?: TablerIcon;
  stretch?: boolean;
}

tsx

for icons, i am using tabler icons in this example.

🎨 Writing styles for individual variants and sizes

create a utility function to manage our class names using the clsx and tailwind-merge packages. this helps us handle class name conflicts and conditional styling:

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

tsx

now, let's define our button styles using tailwind classes. we'll create an object to store our variant and size mappings:

const buttonVariants = {
  base: [
    'inline-flex',
    'items-center',
    'justify-center',
    'rounded-md',
    'font-medium',
    'border',
    'transition-colors',
    'focus-visible:outline-none',
    'focus-visible:ring-2',
    'focus-visible:ring-offset-2',
    'disabled:pointer-events-none',
    'disabled:opacity-50',
  ].join(' '),
  variant: {
    primary: 'bg-blue-500 text-white hover:bg-blue-600 border-transparent',
    secondary: 'bg-white text-black hover:bg-gray-100',
    ghost: 'hover:bg-gray-100 border-transparent',
  },
  size: {
    sm: 'h-9 px-3 text-sm',
    md: 'h-10 px-4 text-sm',
    lg: 'h-11 px-8 text-base',
  },
};

tsx

🧱 writing the main react component

now as the interface and types are prepared, we will write a single component for button using forwardRef, we'll also add handling for our variants, sizes, and icons:

import React from 'react';
import { Loader2 } from 'tabler-icons-react';
import { cn } from '@/lib/utils';
import { buttonVariants } from './styles';

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant = 'primary',
      size = 'md',
      isLoading = false,
      leftIcon: LeftIcon,
      rightIcon: RightIcon,
      stretch = false,
      children,
      disabled,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        disabled={disabled || isLoading}
        className={cn(
          buttonVariants.base,
          buttonVariants.variant[variant],
          buttonVariants.size[size],
          stretch && 'w-full',
          className
        )}
        {...props}
      >
        {isLoading ? (
          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          LeftIcon && (
            <LeftIcon
              className={cn('h-4 w-4', children ? 'mr-2' : '')}
              aria-hidden="true"
            />
          )
        )}
        {children}
        {!isLoading && RightIcon && (
          <RightIcon
            className={cn('h-4 w-4', children ? 'ml-2' : '')}
            aria-hidden="true"
          />
        )}
      </button>
    );
  }
);

Button.displayName = 'Button';

text

✨ ending notes

remember to always consider accessibility when building components. this includes proper aria labels, keyboard navigation, and focus states - which we've included in our base styles. you can extend this pattern to create other components like input fields, select dropdowns, and more complex UI elements while maintaining the same structure and consistency.

keep building!