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 cn utility – Located in packages/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-provided className values.

  • Button implementation – The packages/ui/src/button/button.tsx file demonstrates the core pattern: it implements the base <button> element, handles variant/size logic, and forwards refs using React.forwardRef.

  • Type definitions – packages/ui/src/button/helper.tsx exports TButtonVariant and TButtonSize types, plus styling functions like getButtonStyling that map variants to Tailwind class strings.

  • Export barrel – The packages/ui/src/index.tsx file re-exports all components, enabling clean imports like import { 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:

  1. Import UI primitives from the @plane/ui workspace package.
  2. Define prop interfaces that extend the base component's props (e.g., interface MyCardProps extends React.HTMLAttributes<HTMLDivElement>).
  3. Compose UI elements by assembling primitives in your JSX, passing through variant and size props as needed.
  4. Apply custom styles using the className prop combined with Tailwind utilities; the underlying cn helper handles merging without style conflicts.
  5. 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.tsx that follow consistent ref-forwarding and className override patterns.
  • The cn utility in packages/ui/src/utils/classname.tsx handles Tailwind class merging, preventing style conflicts when overriding defaults.
  • Component composition involves importing primitives like Button and Card, 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →