Propel

Pill

A pill-shaped button that holds a label with optional inline-start and inline-end icons.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { ReleaseTagOutline, CloseOutline } from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill size="sm" startIcon={<Icon icon={ReleaseTagOutline} />} label="Design" />
      <Pill size="sm" endIcon={<Icon icon={CloseOutline} />} label="Dismissible" />
    </div>
  );
}

Installation

import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { IconPill, Pill, PillGroup } from "@makeplane/propel/components/pill";
import { Swatch } from "@makeplane/propel/elements/swatch";

Usage

import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { ReleaseTagOutline, CloseOutline } from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill size="sm" startIcon={<Icon icon={ReleaseTagOutline} />} label="Design" />
      <Pill size="sm" endIcon={<Icon icon={CloseOutline} />} label="Dismissible" />
    </div>
  );
}

Examples

Sizes

size runs xs 20 / sm 24 / md 28 px — the same floor as Button, stopping one rung below its top, because a pill is inline chrome rather than a primary action. Required on both Pill and IconPill, except inside a PillGroup, which shares one size with every segment. See Sizing for the full ladder.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Pill, type PillSize } from "@makeplane/propel/components/pill";
import { ReleaseTagOutline } from "@makeplane/propel/icons";

const SIZES: PillSize[] = ["xs", "sm", "md"];

export default function SizesDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {SIZES.map((size) => (
        <Pill key={size} size={size} startIcon={<Icon icon={ReleaseTagOutline} />} label={size} />
      ))}
    </div>
  );
}

Variants

variant selects the fill treatment: outline (the default, ≈ Button secondary), soft (≈ Button tertiary) or ghost (borderless and transparent at rest — for a trigger whose content is its own box, such as an avatar stack).

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { ReleaseTagOutline } from "@makeplane/propel/icons";

export default function VariantsDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill
        size="sm"
        variant="outline"
        startIcon={<Icon icon={ReleaseTagOutline} />}
        label="Outline"
      />
      <Pill size="sm" variant="soft" startIcon={<Icon icon={ReleaseTagOutline} />} label="Soft" />
      <Pill size="sm" variant="ghost" startIcon={<Icon icon={ReleaseTagOutline} />} label="Ghost" />
    </div>
  );
}

Placeholder

placeholder marks a trigger with no value yet. The chrome stays the same; the label and any glyph that inherits the pill’s colour dim to the placeholder colour, and stay dim while the picker is open.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { CalendarOutline } from "@makeplane/propel/icons";

export default function PlaceholderDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill size="xs" placeholder startIcon={<Icon icon={CalendarOutline} />} label="Due date" />
      <Pill size="xs" startIcon={<Icon icon={CalendarOutline} />} label="Aug 28, 2026" />
    </div>
  );
}

As a picker trigger

Pass render={<MenuTrigger />} (or a PopoverTrigger) and the pill opens the picker. While the popup is up the trigger carries Base UI’s data-popup-open and wears its active look, so the open property reads as pressed for as long as its menu is showing. The reverse graft, <MenuTrigger render={<Pill … />}>, works the same way. SelectTrigger and ComboboxTrigger are not graft targets: use their pill variant when a Select or Combobox needs the same compact label anatomy and active-open treatment, without an automatic chevron.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { Pill } from "@makeplane/propel/components/pill";
import { PriorityHigh, PriorityLow, PriorityMedium } from "@makeplane/propel/icons";

export default function MenuTriggerDemo() {
  return (
    <Menu>
      <Pill
        size="sm"
        startIcon={<Icon icon={PriorityHigh} tint="danger" />}
        label="High"
        render={<MenuTrigger />}
      />
      <MenuContent>
        <MenuItem icon={<Icon icon={PriorityHigh} tint="danger" />} label="High" selected />
        <MenuItem icon={<Icon icon={PriorityMedium} tint="secondary" />} label="Medium" />
        <MenuItem icon={<Icon icon={PriorityLow} tint="secondary" />} label="Low" />
      </MenuContent>
    </Menu>
  );
}

Colour swatch

Swatch is a filled 8px disc that takes the same box an Icon would, so label chips line up with icon chips. fill is any CSS colour — the label’s stored value, not a token. The same part fits a MenuItem’s icon slot.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { Pill } from "@makeplane/propel/components/pill";
import { Swatch } from "@makeplane/propel/elements/swatch";
import { ReleaseTagOutline } from "@makeplane/propel/icons";

export default function SwatchDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill size="xs" startIcon={<Swatch fill="#e5484d" />} label="Bug" />
      <Pill size="xs" startIcon={<Swatch fill="#3e63dd" />} label="2 labels" />
      <Pill size="xs" startIcon={<Icon icon={ReleaseTagOutline} />} label="Labels" placeholder />
      <Menu>
        <Pill
          size="xs"
          startIcon={<Swatch fill="#30a46c" />}
          label="Feature"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem icon={<Swatch fill="#e5484d" />} label="Bug" />
          <MenuItem icon={<Swatch fill="#30a46c" />} label="Feature" selected />
          <MenuItem icon={<Swatch fill="#3e63dd" />} label="Docs" />
        </MenuContent>
      </Menu>
    </div>
  );
}

Label-less triggers

label may be omitted when aria-label is given: the label part is skipped entirely, so a pill can hold an avatar stack or an icon alone. Omitting both is a type error.

Show code
import { Avatar } from "@makeplane/propel/components/avatar";
import { AvatarGroup } from "@makeplane/propel/components/avatar-group";
import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { AddOutline } from "@makeplane/propel/icons";

export default function AvatarTriggerDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill
        size="xs"
        variant="ghost"
        aria-label="Assignees: Amelia Parker, David Wilson"
        startIcon={
          <AvatarGroup size="2xs" render={<span />}>
            <Avatar fallback="AP" />
            <Avatar fallback="DW" />
          </AvatarGroup>
        }
      />
      <Pill size="xs" placeholder startIcon={<Icon icon={AddOutline} />} label="Assignees" />
    </div>
  );
}

A property row

The compact row of pickers on a work item — state, priority, dates, assignees, labels, cycle — built only from propel parts. One chrome at one size; every leading node fills the same glyph box; colour lives on the glyph and the label stays the pill’s text colour; an empty property is a placeholder; a filled assignee stack sits in a ghost pill so avatars are not boxed.

Show code
import { Avatar } from "@makeplane/propel/components/avatar";
import { AvatarGroup } from "@makeplane/propel/components/avatar-group";
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { Pill } from "@makeplane/propel/components/pill";
import { Swatch } from "@makeplane/propel/elements/swatch";
import {
  CalendarOutline,
  CircleDashedOutline,
  RepeatOutline,
  PriorityHigh,
} from "@makeplane/propel/icons";

export default function PropertyRowDemo() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Menu>
        <Pill
          size="xs"
          startIcon={<Icon icon={CircleDashedOutline} tint="tertiary" />}
          label="Backlog"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem
            icon={<Icon icon={CircleDashedOutline} tint="secondary" />}
            label="Backlog"
            selected
          />
          <MenuItem icon={<Icon icon={CircleDashedOutline} tint="secondary" />} label="Todo" />
        </MenuContent>
      </Menu>
      <Menu>
        <Pill
          size="xs"
          startIcon={<Icon icon={PriorityHigh} tint="danger" />}
          label="High"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem icon={<Icon icon={PriorityHigh} tint="danger" />} label="High" selected />
          <MenuItem icon={<Icon icon={PriorityHigh} tint="secondary" />} label="Low" />
        </MenuContent>
      </Menu>
      <Menu>
        <Pill
          size="xs"
          placeholder
          startIcon={<Icon icon={CalendarOutline} />}
          label="Due date"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem label="Today" />
          <MenuItem label="Next week" />
        </MenuContent>
      </Menu>
      <Menu>
        <Pill
          size="xs"
          variant="ghost"
          aria-label="Assignees: Amelia Parker, David Wilson"
          startIcon={
            <AvatarGroup size="2xs" render={<span />}>
              <Avatar fallback="AP" />
              <Avatar fallback="DW" />
            </AvatarGroup>
          }
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem icon={<Avatar size="2xs" fallback="AP" />} label="Amelia Parker" selected />
          <MenuItem icon={<Avatar size="2xs" fallback="DW" />} label="David Wilson" selected />
        </MenuContent>
      </Menu>
      <Menu>
        <Pill
          size="xs"
          startIcon={<Swatch fill="#e5484d" />}
          label="Bug"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem icon={<Swatch fill="#e5484d" />} label="Bug" selected />
          <MenuItem icon={<Swatch fill="#30a46c" />} label="Feature" />
        </MenuContent>
      </Menu>
      <Menu>
        <Pill
          size="xs"
          placeholder
          startIcon={<Icon icon={RepeatOutline} />}
          label="Cycle"
          render={<MenuTrigger />}
        />
        <MenuContent>
          <MenuItem label="Sprint 14" />
          <MenuItem label="Sprint 15" />
        </MenuContent>
      </Menu>
    </div>
  );
}

States

disabled and loading both drop the pill to a transparent fill with a dimmed label; loading also hides the icons, shows a spinner after the label, and blocks clicks while staying focusable.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Pill } from "@makeplane/propel/components/pill";
import { ReleaseTagOutline } from "@makeplane/propel/icons";

export default function StatesDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Pill size="sm" startIcon={<Icon icon={ReleaseTagOutline} />} label="Default" />
      <Pill size="sm" startIcon={<Icon icon={ReleaseTagOutline} />} disabled label="Disabled" />
      <Pill size="sm" loading label="Loading" />
    </div>
  );
}

Icon only

IconPill is a square, icon-only pill and requires an aria-label for its accessible name.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { IconPill } from "@makeplane/propel/components/pill";
import { AddOutline, CloseOutline } from "@makeplane/propel/icons";

export default function IconOnlyDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <IconPill size="xs" aria-label="Remove label" icon={<Icon icon={CloseOutline} />} />
      <IconPill size="sm" aria-label="Remove label" icon={<Icon icon={CloseOutline} />} />
      <IconPill size="md" aria-label="Remove label" icon={<Icon icon={CloseOutline} />} />
      <IconPill size="sm" aria-label="Add label" disabled icon={<Icon icon={AddOutline} />} />
      <IconPill size="sm" aria-label="Add label" loading icon={<Icon icon={AddOutline} />} />
    </div>
  );
}

A connected group

PillGroup joins pills that read as one value — a filter condition, a compound property. The segments stay ordinary Pills and IconPills: the frame overlaps their borders into a single divider, keeps only the outermost corners rounded, and lifts whichever segment is hovered, focused, or holding an open picker so it owns the seam it shares. Each segment is still an independent button with its own picker, so the group needs no roving focus and no selection — name it with aria-label.

size is shared through the group, so a segment can omit it. The scale runs sm and md: there is no 20px step, because a contiguous segment cannot carry the hit ring the standalone 20px pill uses, and shrinking the target below 24px would fail WCAG 2.5.8. For several independent actions that merely share chrome, reach for ButtonGroup instead.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@makeplane/propel/components/menu";
import { IconPill, Pill, PillGroup } from "@makeplane/propel/components/pill";
import { Swatch } from "@makeplane/propel/elements/swatch";
import { BoxesOutline, FilterOutline, CloseOutline } from "@makeplane/propel/icons";

export default function GroupDemo() {
  return (
    <div className="flex items-center gap-2">
      <PillGroup size="md" aria-label="Parent type filter">
        <Menu>
          <Pill
            startIcon={<Icon icon={BoxesOutline} tint="tertiary" />}
            label="Parent type"
            render={<MenuTrigger />}
          />
          <MenuContent>
            <MenuItem label="Parent type" selected />
            <MenuItem label="State" />
          </MenuContent>
        </Menu>
        <Menu>
          <Pill label="is" render={<MenuTrigger />} />
          <MenuContent>
            <MenuItem label="is" selected />
            <MenuItem label="is not" />
          </MenuContent>
        </Menu>
        <Menu>
          <Pill startIcon={<Swatch fill="#3f76ff" />} label="Task" render={<MenuTrigger />} />
          <MenuContent>
            <MenuItem icon={<Swatch fill="#3f76ff" />} label="Task" selected />
            <MenuItem icon={<Swatch fill="#30a46c" />} label="Epic" />
          </MenuContent>
        </Menu>
        <IconPill aria-label="Clear filter" icon={<Icon icon={CloseOutline} />} />
      </PillGroup>
      <IconPill size="md" aria-label="Filter options" icon={<Icon icon={FilterOutline} />} />
    </div>
  );
}

API Reference

Pill

The ready-made pill button: grafts Base UI's Button behavior onto the styled Pill container (behavior outer, the styled button as the render target), with an optional leading node, the PillLabel, an optional trailing node, and a trailing spinner while loading. loading disables the button while keeping it focusable (aria-busy). With no label the label part is skipped entirely (an empty flex child would still consume the pill's gap). A consumer render — render={<MenuTrigger />} to make the pill open a picker — is threaded onto the styled element so the trigger behavior and its data-popup-open land on the real <button>; the reverse graft (<MenuTrigger render={<Pill … />}>) works as well. nativeButton rides along the way it does on Button, for a render that is not a button.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
size"xs" | "sm" | "md"—Scale. Required, except inside a PillGroup — the group shares its own size with every segment, so a segment omits it. Omitting it outside a group throws rather than guessing a rung.
variant"outline" | "soft" | "ghost"outlineFill treatment. outline (≈ Button secondary) is the bordered chip; soft (≈ Button tertiary) is borderless on a filled layer; ghost is borderless and transparent at rest, for a trigger whose content is its own box (an avatar stack).
placeholderbooleanfalseMarks a trigger with no value yet: dims the label and any inherited-tint glyph to the placeholder colour until a value is chosen (still dim while the picker is open).
startIconReactNode—Element rendered before the label (inline-start), e.g. <Icon icon={Tag} /> or <Swatch fill="…" />.
endIconReactNode—Element rendered after the label (inline-end), e.g. <Icon icon={X} />.
loadingbooleanfalseBusy state: hides start/end icons, shows a spinner after the label, and blocks clicks.
nativeButtonboolean—Set false when render swaps the underlying tag away from <button> (an <a href>, a <div>): Base UI then adds role="button", tab focus, and Enter/Space activation, and the element's type="button" default is withheld — it is valid on nothing but a button.
labelstring—
aria-labelstring—

IconPill

The ready-made icon-only pill: grafts Base UI's Button behavior onto the square IconPill container (behavior outer, the styled button as the render target), filling it with the icon (or a spinner while loading). loading disables the button while keeping it focusable (aria-busy). An aria-label is required for the accessible name. A consumer render (render={<MenuTrigger />}) is threaded onto the styled element, so the trigger behavior and its data-popup-open land on the real <button> and the pill wears its open look.

PropTypeDefaultDescription
icon(required)ReactNode—The icon element, usually <Icon icon={...} />.
aria-labelstring—Defines a string value that labels the current element. Required: icon-only pills have no visible text, so they must be labeled. @see aria-labelledby.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
size"xs" | "sm" | "md"—Scale. Required, except inside a PillGroup — the group shares its own size with every segment, so a segment omits it. Omitting it outside a group throws rather than guessing a rung.
loadingbooleanfalseBusy state: swaps the icon for a spinner and blocks clicks.
nativeButtonboolean—Set false when render swaps the underlying tag away from <button> (an <a href>, a <span>): Base UI then adds role="button", tab focus, and Enter/Space activation, and the element's type="button" default is withheld — it is valid on nothing but a button.

PillGroup

The ready-made connected pill group: a group-role frame that shares size with every Pill and IconPill inside it via context (a segment's own size still wins). There is no Base UI group primitive — the segments are independent Base UI Buttons, each free to open its own picker; the frame contributes only the shared chrome, the shared rung, and the role. Name the group for assistive tech via aria-label. Built for a compound control whose parts read as one value — a filter condition (Parent type · is · Task · ✕), a property row. For several independent actions that merely share chrome, reach for ButtonGroup.

PropTypeDefaultDescription
size(required)"sm" | "md"—
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

Swatch

A filled 8px colour disc centred in the inherited --node-size box, so it sits exactly where an icon would: the leading node of a label pill, or a MenuItem's icon slot. Decorative (aria-hidden) — the adjacent label carries the name. fill is the one styling input a consumer supplies, because the colour is data rather than a token; the part applies it itself as --swatch-fill, so no style prop is exposed.

PropTypeDefaultDescription
fill(required)string—The disc's colour — any CSS <color> (a label's stored hex, an rgb() string).
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>—Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.