# Interactive Color Panel
*URL: https://www.grettech.com/components/color-panel*

A floating toolkit panel for drawing apps or design tools.

- Controls adapt to the active tool (stroke, eraser, shapes)
- Stroke width slider
- Labelled preset colour swatches
- Collapses to a button on smaller screens

## Install with the shadcn CLI
```bash
npx shadcn@latest add https://www.grettech.com/api/registry/color-panel
```
With its demo: `npx shadcn@latest add https://www.grettech.com/api/registry/interactive-color-panel-demo`

**Dependencies:** npm: clsx, tailwind-merge, lucide-react; shadcn/ui: button, label, slider, switch

## Manual installation
**Step 1: Install dependencies**
Install the packages this component uses and the shadcn/ui components it builds on. It also needs the `cn` helper in `lib/utils.ts`, which `npx shadcn@latest init` adds.

```bash
npm install clsx tailwind-merge lucide-react
npx shadcn@latest add button label slider switch
```

**Step 2: Copy the source code**
Add these files to your project components directory.

File: `components/ui/color-panel.tsx`
```tsx
"use client"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { Palette, X } from "lucide-react"
import * as React from "react"
import { useState } from "react"

export interface ColorPanelProps extends React.HTMLAttributes<HTMLDivElement> {
  tool?: string
  fill?: boolean
  onFillChange?: (fill: boolean) => void
  brushSize?: number
  onBrushSizeChange?: (size: number) => void
  color?: string
  onColorChange?: (color: string) => void
  fillColor?: string
  onFillColorChange?: (color: string) => void
}

const PRESET_COLORS = [
  { value: "#000000", name: "Black" },
  { value: "#ffffff", name: "White" },
  { value: "#ef4444", name: "Red" },
  { value: "#f97316", name: "Orange" },
  { value: "#eab308", name: "Yellow" },
  { value: "#22c55e", name: "Green" },
  { value: "#3b82f6", name: "Blue" },
  { value: "#a855f7", name: "Purple" },
]

function Swatches({
  label,
  value,
  onChange,
}: {
  label: string
  value: string
  onChange?: (color: string) => void
}) {
  return (
    <div
      role="radiogroup"
      aria-label={label}
      className="grid grid-cols-4 gap-2"
    >
      {PRESET_COLORS.map((c) => {
        const selected = value.toLowerCase() === c.value
        return (
          <button
            key={c.value}
            type="button"
            role="radio"
            aria-checked={selected}
            aria-label={c.name}
            title={c.name}
            className={cn(
              "size-7 rounded-full border border-black/10 shadow-sm transition-transform outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:border-white/15",
              selected &&
                "ring-2 ring-primary ring-offset-2 ring-offset-background"
            )}
            style={{ backgroundColor: c.value }}
            onClick={() => onChange?.(c.value)}
          />
        )
      })}
    </div>
  )
}

export const ColorPanel = React.forwardRef<HTMLDivElement, ColorPanelProps>(
  (
    {
      tool = "pencil",
      fill = false,
      onFillChange,
      brushSize = 5,
      onBrushSizeChange,
      color = "#000000",
      onColorChange,
      fillColor = "#ffffff",
      onFillColorChange,
      className,
      ...props
    },
    ref
  ) => {
    const [isOpen, setIsOpen] = useState(false)
    const fillSwitchId = React.useId()

    // Only show the panel if a tool that uses color/stroke is active
    if (tool === "image" || tool === "select" || tool === "pan") return null

    return (
      <>
        <Button
          variant="outline"
          size="icon"
          className={cn(
            "absolute top-20 right-4 z-40 h-10 w-10 rounded-xl border border-border bg-background/80 shadow-lg backdrop-blur-md md:top-1/2 md:-translate-y-1/2 lg:hidden",
            isOpen ? "hidden" : "flex"
          )}
          onClick={() => setIsOpen(true)}
          aria-label="Open properties panel"
        >
          <Palette className="h-5 w-5" />
        </Button>

        <div
          ref={ref}
          className={cn(
            "absolute top-20 right-2 z-40 max-h-[calc(100vh-6rem)] w-52 flex-col gap-3 overflow-y-auto rounded-2xl border border-border/60 bg-background/85 p-3 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.25)] backdrop-blur-md md:top-1/2 md:right-4 md:w-64 md:-translate-y-1/2 md:gap-4 md:p-4",
            isOpen ? "flex" : "hidden lg:flex",
            className
          )}
          {...props}
        >
          <div className="mb-1 flex w-full items-center justify-between border-b border-border/50 pb-1">
            <span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
              Properties
            </span>
            <Button
              variant="ghost"
              size="icon"
              className="-mr-2 h-6 w-6 lg:hidden"
              onClick={() => setIsOpen(false)}
              aria-label="Close properties panel"
            >
              <X className="h-4 w-4" />
            </Button>
          </div>

          {/* Stroke Width */}
          <div className="flex flex-col gap-3">
            <div className="flex items-center justify-between">
              <span className="text-sm font-medium text-foreground">
                {tool === "eraser" ? "Eraser Size" : "Stroke Width"}
              </span>
              <span className="font-mono text-xs text-muted-foreground tabular-nums">
                {brushSize}px
              </span>
            </div>
            <Slider
              value={[brushSize]}
              // Radix passes number[], Base UI may pass a single number
              onValueChange={(val: number | readonly number[]) =>
                onBrushSizeChange?.(typeof val === "number" ? val : val[0])
              }
              max={50}
              min={1}
              step={1}
              aria-label={tool === "eraser" ? "Eraser size" : "Stroke width"}
              className="cursor-pointer"
            />
          </div>

          {tool !== "eraser" && (
            <>
              <div className="h-px w-full bg-border/50" />

              {/* Stroke Color */}
              {((tool !== "rectangle" && tool !== "circle") || !fill) && (
                <div className="flex flex-col gap-2">
                  <span className="text-sm font-medium text-foreground">
                    Stroke Color
                  </span>
                  <Swatches
                    label="Stroke color"
                    value={color}
                    onChange={onColorChange}
                  />
                </div>
              )}

              {/* Fill Color */}
              {(tool === "rectangle" || tool === "circle") && (
                <div className="flex flex-col gap-3">
                  <div className="flex items-center justify-between gap-2 border-t border-border/50 pt-3">
                    <Label
                      htmlFor={fillSwitchId}
                      className="text-sm font-medium text-foreground"
                    >
                      Fill Shape
                    </Label>
                    <Switch
                      id={fillSwitchId}
                      checked={fill}
                      onCheckedChange={(checked) => onFillChange?.(checked)}
                    />
                  </div>
                  {fill && (
                    <Swatches
                      label="Fill color"
                      value={fillColor}
                      onChange={onFillColorChange}
                    />
                  )}
                </div>
              )}
            </>
          )}
        </div>
      </>
    )
  }
)
ColorPanel.displayName = "ColorPanel"

```

**Step 3: Framework Adjustments (Optional)**
If you are manually copying this code into a Vite or Remix project (without using the CLI), make sure to remove the `"use client";` directive from the very top of the file.

## Props
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `tool` | `string` | `"pencil"` | No | The active tool. The panel hides for "image", "select" and "pan", shows the size only for "eraser", and adds fill options for "rectangle" and "circle". |
| `brushSize` | `number` | `5` | No | Stroke (or eraser) width in px, from 1 to 50. |
| `onBrushSizeChange` | `(size: number) => void` | - | No | Called with the new width while the slider moves. |
| `color` | `string` | `"#000000"` | No | Selected stroke colour as a hex value. The swatches are #000000, #ffffff, #ef4444, #f97316, #eab308, #22c55e, #3b82f6 and #a855f7. |
| `onColorChange` | `(color: string) => void` | - | No | Called with the hex value of the clicked stroke swatch. |
| `fill` | `boolean` | `false` | No | Whether shapes are filled (rectangle and circle only). |
| `onFillChange` | `(fill: boolean) => void` | - | No | Called when the fill switch is toggled. |
| `fillColor` | `string` | `"#ffffff"` | No | Selected fill colour as a hex value, from the same swatches. |
| `onFillColorChange` | `(color: string) => void` | - | No | Called with the hex value of the clicked fill swatch. |
| `className` | `string` | - | No | Extra classes for the floating panel. |
| `...props` | `React.HTMLAttributes<HTMLDivElement>` | - | No | Any other attributes (id, style, aria-*, etc.), passed to the floating panel. |

## Usage
```tsx
"use client"

import { useState } from "react"
import { ColorPanel } from "@/components/ui/color-panel"

export default function InteractiveColorPanel() {
  const [tool] = useState("rectangle")
  const [color, setColor] = useState("#3b82f6")
  const [fill, setFill] = useState(false)
  const [fillColor, setFillColor] = useState("#ffffff")
  const [brushSize, setBrushSize] = useState(5)

  return (
    <div className="relative flex h-full min-h-[500px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_12%,transparent)_1px,transparent_1px)] bg-size-[20px_20px]">
      {/* Mock drawing area */}
      <div
        className="absolute top-1/2 left-1/2 flex size-56 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-xl border-2 bg-background shadow-sm transition-all duration-300 lg:-translate-x-[70%]"
        style={{
          borderColor: color,
          borderWidth: `${brushSize}px`,
          backgroundColor: fill ? fillColor : "var(--background)",
          borderStyle: tool === "eraser" ? "dashed" : "solid",
        }}
      >
        <span className="pointer-events-none font-mono text-xs text-muted-foreground select-none">
          Mock Canvas
        </span>
      </div>

      <ColorPanel
        tool={tool}
        color={color}
        onColorChange={setColor}
        fill={fill}
        onFillChange={setFill}
        fillColor={fillColor}
        onFillColorChange={setFillColor}
        brushSize={brushSize}
        onBrushSizeChange={setBrushSize}
      />
    </div>
  )
}

export function InteractiveColorPanelDemo() {
  return (
    <div className="flex w-full justify-center rounded-xl border bg-muted/20 p-8">
      <InteractiveColorPanel />
    </div>
  )
}

```
