How to Create Custom Components Using the @plane/ui Component Library
The @plane/ui library provides type-safe React primitives that accept className overrides and forward refs, allowing you to compose custom components by extending existing prop interfaces and merging Tailwind styles via the built-in cn utility.
The @plane/ui package is the official React component library used throughout the Plane project management application. It offers a collection of reusable, type-safe UI primitives built on Tailwind CSS that follow consistent design patterns. When you need to create custom components using the @plane/ui component library, you leverage its architectural conventions—specifically ref forwarding, variant props, and the cn class merging utility—to maintain type safety and design consistency across the makeplane/plane codebase.
Understanding the @plane/ui Architecture
Before building custom components, you need to understand how the library structures its primitives. Each component in the @plane/ui package follows a strict pattern: functional components that forward refs, accept a className prop for style overrides, and optionally define variant-specific props like variant, size, or loading.
Key architectural pieces include:
-
The
cnutility – Located inpackages/ui/src/utils/classname.tsx, this wrapper merges Tailwind classes using a clsx-style algorithm. Every UI component uses this to concatenate default styles with user-providedclassNamevalues. -
Button implementation – The
packages/ui/src/button/button.tsxfile demonstrates the core pattern: it implements the base<button>element, handles variant/size logic, and forwards refs usingReact.forwardRef. -
Type definitions –
packages/ui/src/button/helper.tsxexportsTButtonVariantandTButtonSizetypes, plus styling functions likegetButtonStylingthat map variants to Tailwind class strings. -
Export barrel – The
packages/ui/src/index.tsxfile re-exports all components, enabling clean imports likeimport { Button, Card } from "@plane/ui".
How to Create Custom Components Using @plane/ui
Building on top of Plane's UI primitives follows a consistent five-step pattern:
- Import UI primitives from the
@plane/uiworkspace package. - Define prop interfaces that extend the base component's props (e.g.,
interface MyCardProps extends React.HTMLAttributes<HTMLDivElement>). - Compose UI elements by assembling primitives in your JSX, passing through variant and size props as needed.
- Apply custom styles using the
classNameprop combined with Tailwind utilities; the underlyingcnhelper handles merging without style conflicts. - Export the component for reuse across your application or design system.
This approach preserves TypeScript autocomplete and ensures your custom components respect the same accessibility and styling conventions as the core library.
Practical Implementation Examples
Composing Primitives: Building an InfoCard
The simplest pattern involves composing multiple @plane/ui primitives into a higher-level component. This example combines Card and Button to create a reusable info panel:
// src/components/InfoCard.tsx
import React from "react";
import { Card, Button } from "@plane/ui";
export interface InfoCardProps {
title: string;
description: string;
actionLabel: string;
onAction: () => void;
className?: string;
}
/**
* A reusable card that shows a title, description and a primary action button.
*/
export const InfoCard: React.FC<InfoCardProps> = ({
title,
description,
actionLabel,
onAction,
className = "",
}) => (
<Card className={className}>
<div className="p-4">
<h3 className="text-xl font-medium">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{description}</p>
</div>
<div className="flex justify-end p-4 pt-0">
<Button variant="primary" onClick={onAction}>
{actionLabel}
</Button>
</div>
</Card>
);
The Card component handles layout and default padding, while Button receives its standard variant prop. The wrapper passes className directly to Card, which internally calls the cn utility to merge styles without duplication.
Extending Existing Components: Creating a LoadingButton
When you need to add functionality to an existing primitive, extend its prop interface. This LoadingButton wraps the base Button to add a loading state while preserving all original props:
// src/components/LoadingButton.tsx
import React from "react";
import { Button, type ButtonProps } from "@plane/ui";
export interface LoadingButtonProps extends ButtonProps {
/** When true, the button shows a spinner and disables user interaction */
loading?: boolean;
}
/**
* Wraps @plane/ui's Button to add a built-in loading state.
*/
export const LoadingButton = React.forwardRef<
HTMLButtonElement,
LoadingButtonProps
>(({ loading = false, children, ...rest }, ref) => (
<Button ref={ref} disabled={loading} {...rest}>
{loading ? (
<span className="flex items-center space-x-2">
<span className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
<span>{children}</span>
</span>
) : (
children
)}
</Button>
));
By extending ButtonProps from packages/ui/src/button/button.tsx, this component preserves all standard button options including variant, size, and appendIcon. The ref forwarding ensures parents can still access the underlying <button> element for focus management or form integration.
Custom Layouts: AvatarBadge with Overlay Positioning
For components requiring specific visual arrangements, combine primitives with Tailwind positioning utilities. This AvatarBadge overlays a Badge component on an Avatar:
// src/components/AvatarBadge.tsx
import React from "react";
import { Avatar, Badge } from "@plane/ui";
export interface AvatarBadgeProps {
src: string;
alt: string;
label: string;
badgeClassName?: string;
}
/**
* Renders an avatar with a badge overlay positioned at the bottom-right.
*/
export const AvatarBadge: React.FC<AvatarBadgeProps> = ({
src,
alt,
label,
badgeClassName = "",
}) => (
<div className="relative inline-block">
<Avatar src={src} alt={alt} />
<Badge
className={`absolute bottom-0 right-0 transform translate-x-1/2 translate-y-1/2 ${badgeClassName}`}
>
{label}
</Badge>
</div>
);
Both Avatar and Badge are exported from packages/ui/src/index.tsx and include built-in accessibility features. The wrapper uses Tailwind's relative and absolute positioning classes rather than custom CSS, maintaining consistency with the library's utility-first approach.
Summary
- The @plane/ui package exposes type-safe React primitives in
packages/ui/src/index.tsxthat follow consistent ref-forwarding andclassNameoverride patterns. - The
cnutility inpackages/ui/src/utils/classname.tsxhandles Tailwind class merging, preventing style conflicts when overriding defaults. - Component composition involves importing primitives like
ButtonandCard, extending their TypeScript interfaces, and assembling them with Tailwind utilities. - Ref forwarding is preserved throughout the component tree, ensuring access to underlying DOM elements for form handling and focus management.
- All styling uses Tailwind CSS classes rather than inline styles or CSS modules, maintaining visual consistency with Plane's design system.
Frequently Asked Questions
What is the cn utility in @plane/ui?
The cn utility is a class name merging function located in packages/ui/src/utils/classname.tsx. It combines Tailwind CSS classes using a clsx-style algorithm, ensuring that default component styles and user-provided className props merge correctly without duplication or specificity conflicts.
How do I extend Button props when creating a custom component?
Import the ButtonProps type from @plane/ui and extend it in your interface definition. As shown in the LoadingButton example, this preserves all standard button attributes—such as variant, size, and disabled—while allowing you to add custom props like loading states.
Where are the base component styles defined in the Plane repository?
Base styling logic for components like Button resides in packages/ui/src/button/helper.tsx, which exports type definitions (TButtonVariant, TButtonSize) and styling functions (getButtonStyling). These map prop values to specific Tailwind class strings that the component applies via the cn utility.
Can I use @plane/ui components outside of the Plane application?
Yes. The library is structured as a workspace package within the monorepo, exporting components through packages/ui/src/index.tsx. You can import components using import { Button, Card } from "@plane/ui" in any React application that has the package installed, maintaining the same TypeScript safety and Tailwind configuration.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →