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.
Carousel
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;