Custom components

    Like everything in life, nothing is perfect. While shadcn components excel in many areas, they, like standard web elements, lacks support for exit animations, animating height/width to auto and transitions between multiple locations.

    To address this, I chose to integrate motion to enable smooth (60-120fps) and beautiful animations for scroll, enter, and exit states. Examples include:

    • Admin dashboard sidebar transitions
    • Features carousel on the landing page
    • Reordering images in the product edit form (admin dashboard)

    motion has easy learning curve, making it accessible. However, if you prefer not to use it, you're not obligated to do so in most custom components and they can be replaced by a shadcn alternatives.

    As shadcn creates composable and reusable components we already do the same to keep things unified

    When using the custom components

    • You don't have to import all individual elements. In most cases, only use what you need
    • style any individual imported element as you wish so don't be shy to use the className prop!

    Custom components are stored under /src/components/ui-custom and here is the list, click on one to see an example on how to use it.

    Create a carousel to show images or any other element with the ability to have previews above or below the carousel slider

    import {
      Carousel,
      CarouselBody,
      CarouselContent,
      CarouselItem,
      CarouselNav,
      CarouselNavItem,
      CarouselNext,
      CarouselPrevious,
    } from "components/ui-custom/carousel";
    import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from "lucide-react";
    
    const items = [1, 2, 3, 4, 5];
    
    const ExamplePage = () => {
      return (
        <Carousel className="mx-auto max-w-4xl space-y-8">
          <CarouselBody>
            <CarouselContent>
              {items.map((item, index) => (
                <CarouselItem key={item.id} drag={false} className="my-auto">
                  <p>Slide {index}</p>
                </CarouselItem>
              ))}
            </CarouselContent>
            {/* */}
            {/* This places carousel buttons on top of the carousel */}
            {/* */}
            <CarouselPrevious>
              <ArrowLeftCircleIcon />
            </CarouselPrevious>
            <CarouselNext>
              <ArrowRightCircleIcon />
            </CarouselNext>
          </CarouselBody>
    
          {/* You can configure the <CarouselNav /> by modifying the config prop */}
          <CarouselNav config={{ activeCentered: true, max: 3 }}>
            {items.map((item, index) => (
              <CarouselNavItem
                key={item.id}
                // layoutId is required to animate entering/exiting elements
                layoutId={item.id}
                // index is required to for the component to know which item has been clicked
                index={index}
                // you can style active carousel nav items by using `data-[active="true"]` selector
                className={`data-[active="true"]:bg-primary`}
              >
                <p>Nav item {index}</p>
              </CarouselNavItem>
            ))}
          </CarouselNav>
    
          {/* */}
          {/* If you don't prefer the buttons to be on top of the carousel set position="static" prop  */}
          {/* */}
          <div className="flex items-center gap-2">
            <CarouselPrevious position="static">
              <ArrowLeftCircleIcon />
            </CarouselPrevious>
            <CarouselNext position="static">
              <ArrowRightCircleIcon />
            </CarouselNext>
          </div>
        </Carousel>
      );
    };
    
    export default ExamplePage;
    

    UIForm

    The UIForm extends over shadcn form component to have some default styles on the form and add a formfield that will automatically disable all form inputs/buttons

    import { Form, FormControl, FormField, FormItem } from "components/ui/form";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { useForm } from "react-hook-form";
    import { z } from "zod";
    import {
      UIForm,
      UIFormDescription,
      UIFormGroup,
      UIFormMessage,
    } from "components/ui-custom/form";
    import { anyAsyncFunction } from "lib/api";
    
    const schema = z.object({
      email: z.string().min(1),
      password: z.string().min(1),
    });
    
    // infer the object type using zod helper method
    type Schema = z.infer<typeof schema>;
    
    const ExamplePage = () => {
      const { mutate, isPending } = useMutation({
        // add async function to trigger
        mutationFn: anyAsyncFunction,
        onSuccess: () => {
          // on success code
        },
      });
      const form = useForm<Schema>({
        // add default values
        defaultValues: { email: "", password: "" },
        // add zod validation
        resolver: zodResolver(schema),
      });
    
      return (
        <Form {...form}>
          <UIForm
            className="..."
            // make the entire form disabled (input, textarea and button elements only)
            disabled={isPending}
            onSubmit={form.handleSubmit((v) => mutate(v))}
          >
            {/* Add title and description to the form */}
            <UIFormDescription descriptionTitle="Title">
              Description
            </UIFormDescription>
    
            {/* Group multiple inputs together */}
            <UIFormGroup>
              <FormField
                name="email"
                control={form.control}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Email</FormLabel>
                    <FormControl>
                      <Input placeholder="email" type="email" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                name="password"
                control={form.control}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Password</FormLabel>
                    <FormControl>
                      <Input placeholder="password" type="password" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </UIFormGroup>
    
            <Button
              disabled={disabled}
              isLoading={isPending}
              type="submit"
              className="w-full"
            >
              Join
            </Button>
    
            {/* Add a global form message */}
            <UIFormMessage>a verification email has been sent</UIFormMessage>
          </UIForm>
        </Form>
      );
    };
    
    export default ExamplePage;
    

    List

    Create a list of text with many variants included

    const ExamplePage = () => {
      return (
        {/* Modify the variant prop to get different variants of the list */}
        <List variant="success">
          <ListTitle>Features</ListTitle>
          <ListContent>
            <ListItem>
              <CheckIcon className="mr-2 inline" />
              Feature 1
            </ListItem>
            <ListItem>
              <CheckIcon className="mr-2 inline" />
              Feature 2
            </ListItem>
            <ListItem>
              <CheckIcon className="mr-2 inline" />
              Feature 3
            </ListItem>
          </ListContent>
        </List>
      );
    };
    
    export default ExamplePage;
    

    NotFound

    Create a page or a section where you have nothing to show (e.g. not found page, no items in a list of products)

    import {
      NotFound,
      NotFoundHeader,
      NotFoundContent,
      NotFoundLink,
      NotFoundTagline,
    } from "components/ui-custom/not-found";
    
    const ExamplePage = () => {
      return (
        <NotFound className="h-screen">
          <NotFoundHeader>
            <img src="image_src_url" alt="" />
          </NotFoundHeader>
    
          <NotFoundContent>
            <NotFoundTagline>404</NotFoundTagline>
            <H1>Page not found.</H1>
            <P>Looking for something else?</P>
            <div className="flex gap-2">
              <NotFoundLink href="/">Go home</NotFoundLink>
              <NotFoundLink href="/products">View Products</NotFoundLink>
              <NotFoundLink href={`mailto:${config.email.replyTo}`}>
                Send us a message
              </NotFoundLink>
            </div>
          </NotFoundContent>
        </NotFound>
      );
    };
    
    export default ExamplePage;
    

    Quote

    Create a quote that has many variants

    import {
      Quote,
      QuoteContent,
      QuoteDescription,
      QuoteFigCaption,
      QuoteFigure,
      QuoteFooter,
      QuoteHandle,
      QuoteHeader,
      QuoteName,
      QuoteImage,
    } from "components/ui-custom/quote";
    import { QuoteIcon } from "lucide-react";
    
    const ExamplePage = () => {
      return (
        // Quote accepts extra variant prop
        <Quote className="max-w-xl" variant="default">
          <QuoteContent>
            <QuoteHeader>
              <QuoteIcon size={16} className="fill-black dark:fill-white" />
            </QuoteHeader>
    
            <QuoteDescription>Some example text</QuoteDescription>
    
            <QuoteFooter>
              <QuoteFigure>
                <QuoteImage>
                  <img src="" alt="" />
                </QuoteImage>
                <QuoteFigCaption>
                  <QuoteName>Raffi</QuoteName>
                  <QuoteHandle>@raffiwebdev</QuoteHandle>
                </QuoteFigCaption>
              </QuoteFigure>
            </QuoteFooter>
          </QuoteContent>
        </Quote>
      );
    };
    
    export default ExamplePage;
    

    Reorder

    Creates a Reorder-able list (e.g. gallery images)

    import {
      Reorder,
      ReorderItem,
      ReorderList,
      ReorderPlaceholder,
    } from "components/ui-custom/reorder";
    import { useState } from "react";
    
    const ExamplePage = () => {
      const [items, setItems] = useState([1, 2, 3, 4, 5]);
    
      return (
        <Reorder
          // the array of objects you want to reorder
          data={items}
          // the updated ordered list
          onReorder={setItems}
          disabled={disabled}
          className={clsx(
            "transition-opacity duration-500",
            disabled ? "opacity-50" : "opacity-100",
          )}
        >
          <ReorderList
            className="bg-muted grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-4 rounded-lg p-2"
            onReorderEnd={() => {
              // do something after reorder like updating the api
              // this triggers after the user has dropped the dragging item
              // don't do it on `onReorder` as `onReorder` is always updating on drag
            }}
          >
            {items.map((item) => {
              return (
                <ReorderItem
                  key={item.id}
                  // required to enable reorder animations
                  layoutId={item.id}
                  // required to check which item we are dragging
                  dataItem={item}
                  className="aspect-square rounded-lg border border-neutral-300 first:col-span-2 first:row-span-2"
                >
                  {/* add your elements here*/}
    
                  {/* shows a placeholder instead of empty space instead of the dragging item */}
                  <ReorderPlaceholder className="dark:bg-muted-foreground bg-slate-200" />
                </ReorderItem>
              );
            })}
          </ReorderList>
        </Reorder>
      );
    };
    
    export default ExamplePage;
    

    Section

    Creates a section that can be used in any page

    import {
      Section,
      SectionDescription,
      SectionHeader,
      SectionTitle,
    } from "components/ui-custom/section";
    
    const ExamplePage = () => {
      return (
        <Section className="flex min-h-[50vh] flex-col justify-center">
          {/* SectionHeader accepts align prop */}
          <SectionHeader align="center">
            <SectionTitle>Section Title</SectionTitle>
            <SectionDescription>Section Description</SectionDescription>
          </SectionHeader>
    
          {/* content goes here */}
        </Section>
      );
    };
    
    export default ExamplePage;
    

    Typography

    Pre-styled typography elements

    import {
      H1,
      H2,
      H3,
      H4,
      H5,
      H6,
      P,
      Blockquote,
      Small,
      Muted,
    } from "components/ui-custom/typography";
    
    const ExamplePage = () => {
      return (
        <>
          <H1>Main Heading</H1>
          <H2>Subheading</H2>
          <H3>Section Title</H3>
          <H4>Subsection Title</H4>
          <H5>Smaller Section Title</H5>
          <H6>Smallest Title</H6>
          <P>
            This is a paragraph of text demonstrating the usage of the{" "}
            <code>P</code> component. It renders standard paragraph text with
            appropriate styling.
          </P>
          <Blockquote>
            "This is a blockquote component for quoting text or emphasizing
            content."
          </Blockquote>
          <Small>
            This text is small, styled with the <code>Small</code> component.
          </Small>
          <Muted>
            This text is muted, styled with the <code>Muted</code> component.
          </Muted>
        </>
      );
    };
    
    export default ExamplePage;
    

    Video

    Creates a video component that has a play button in the middle and makes sure that videos load on mobile devices by showing frame at 0.1 second instead of black screen

    import { Video, VideoPlayer } from "components/ui-custom/video";
    
    const ExamplePage = () => {
      return (
        <Video>
          <VideoPlayer src={"video_url"} />
        </Video>
      );
    };
    
    export default ExamplePage;