Files
the-collective-hub/.github/instructions/bits-ui.instructions.md
KungRaseri b192cd53ba docs(copilot): add Copilot instructions for The Collective Hub
- Add comprehensive project overview and core philosophy
- Document file structure reference for the codebase
- Create key files reference table for task-specific guidance
- Include multi-tenant guidelines and site resolution flow
2026-06-05 23:46:15 -07:00

476 lines
21 KiB
Markdown

---
description: 'Use when integrating Bits UI headless components into The Collective Hub. Covers installation, the child snippet pattern, component catalog, Tailwind v4 integration, and migration guidance for overlapping existing UI components.'
applyTo: 'src/**/*.svelte'
---
# Bits UI Integration
## Overview
[Bits UI](https://bits-ui.com/) is a **headless component library for Svelte 5**, built and maintained by Hunter Johnston ([@huntabyte](https://github.com/huntabyte)). It is the successor to Melt UI, rebuilt exclusively for Svelte 5 runes.
| Property | Description |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| **Headless** | Ships unstyled — you bring your own CSS/Tailwind classes |
| **Accessibility-first** | WAI-ARIA compliant with keyboard navigation, focus management, and screen reader support |
| **TypeScript-native** | Full type coverage with exported type helpers |
| **Svelte 5 only** | Built exclusively for runes (`$state`, `$derived`, `$props`, `$bindable`, `Snippet`) |
## Installation
```bash
npm install bits-ui
```
For date/time components, also install the peer dependency:
```bash
npm install @internationalized/date
```
No additional setup or configuration required — import directly:
```svelte
<script lang="ts">
import { Accordion, Button, Dialog } from 'bits-ui';
</script>
```
## Component Catalog
### Interactive / Disclosure
| Component | Description |
| ---------------- | --------------------------------------- |
| `Accordion` | Expandable/collapsible content sections |
| `AlertDialog` | Modal dialog for urgent confirmations |
| `Collapsible` | Single collapsible panel |
| `Dialog` | Modal dialog overlay |
| `Tabs` | Tabbed content switcher |
| `DropdownMenu` | Menu anchored to a trigger button |
| `ContextMenu` | Right-click context menu |
| `Menubar` | Horizontal menu bar with nested menus |
| `NavigationMenu` | Responsive nav menu with submenus |
| `Popover` | Floating content anchored to a trigger |
| `Tooltip` | Hover tooltip anchored to an element |
| `LinkPreview` | Floating preview card for hyperlinks |
| `Command` | ⌘K-style command palette |
| `Combobox` | Autocomplete input with dropdown |
| `Select` | Single/multi-select dropdown |
### Form / Input
| Component | Description |
| ----------------- | -------------------------------- |
| `Button` | Unstyled button primitive |
| `Checkbox` | Checkbox input |
| `Label` | Accessible label |
| `RadioGroup` | Radio button group |
| `Switch` | Toggle switch |
| `Toggle` | Single toggle button |
| `ToggleGroup` | Grouped toggle buttons |
| `PinInput` | Split-digit code input |
| `Slider` | Range slider |
| `Meter` | Meter/guage display |
| `Progress` | Progress bar |
| `RatingGroup` | Star/score rating input |
| `Calendar` | Single date calendar |
| `RangeCalendar` | Date range calendar |
| `DateField` | Single date segmented input |
| `DateRangeField` | Date range segmented input |
| `DatePicker` | Calendar popover for single date |
| `DateRangePicker` | Calendar popover for date range |
| `TimeField` | Time segmented input |
| `TimeRangeField` | Time range segmented input |
### Layout / Display
| Component | Description |
| ------------- | ------------------------------- |
| `AspectRatio` | Fixed aspect ratio container |
| `Avatar` | Image placeholder with fallback |
| `Badge` | Inline status/label indicator |
| `Separator` | Horizontal/vertical divider |
| `Pagination` | Page navigation control |
| `ScrollArea` | Custom scrollbar container |
| `Toolbar` | Toolbar button group |
### Utilities & Type Helpers
| Export | Description |
| ------------------------ | ---------------------------------------------------------- |
| `mergeProps` | Merge multiple prop objects with handler chaining |
| `useId` | Generate unique IDs for accessibility wiring |
| `Portal` | Teleport content to a different DOM node |
| `isUsingKeyboard` | Track keyboard vs. mouse interaction |
| `BitsConfig` | Global Bits UI configuration provider |
| `computeCommandScore` | Score items for Command/Combobox filtering |
| `WithElementRef` | Type helper for element ref forwarding |
| `WithoutChild` | Type helper omitting the child snippet prop |
| `WithoutChildren` | Type helper omitting children snippet props |
| `WithoutChildrenOrChild` | Type helper omitting both child and children snippet props |
## Svelte 5 Runes Integration
Bits UI is built exclusively for Svelte 5 runes. It uses a **compound component pattern** (`Component.Root`, `Component.Item`, `Component.Trigger`, `Component.Content`, etc.) throughout.
### Bindable Props
Control component state with two-way bindable props:
```svelte
<script lang="ts">
import { Dialog } from 'bits-ui';
let open = $state(false);
</script>
<Dialog.Root bind:open>
<!-- ... -->
</Dialog.Root>
```
Common bindable props across components:
| Pattern | Example |
| ------------------ | ------------------------------------------ |
| `bind:open` | Dialog, Popover, DropdownMenu, Collapsible |
| `bind:value` | Select, Combobox, Tabs, RadioGroup |
| `bind:placeholder` | DateField, DatePicker |
| `bind:date` | Calendar, DatePicker |
### Callback Props
Bits UI uses an `on{EventName}Change` pattern for change callbacks:
```svelte
<Select.Root
bind:value
onValueChange={(val) => console.log('selected', val)}
>
```
## The `child` Snippet Pattern (Critical)
Bits UI does **not** use `<slot>`. Instead, it exposes a **`child` snippet** pattern for render delegation.
### Basic Usage
```svelte
<Accordion.Trigger>
{#snippet child({ props })}
<button {...props} class="my-custom-class">Trigger Label</button>
{/snippet}
</Accordion.Trigger>
```
The `child` snippet receives an object with `props` (and optionally `wrapperProps` for floating components). You must spread `{...props}` onto the root element of your rendered markup to wire up accessibility, events, and state attributes.
### Floating Components (Two-Level Wrapper)
For **floating components**`Popover`, `Tooltip`, `DropdownMenu`, `Select`, `Combobox`, `DatePicker`, `Command` — Bits UI requires a **two-level wrapper** inside the `child` snippet:
```svelte
<script lang="ts">
import { Popover, Button } from 'bits-ui';
let open = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button {...props} class="btn-cyan">Menu</button>
{/snippet}
</Popover.Trigger>
<Popover.Content>
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div {...props} class="shadow-card w-48 border border-slate-700 bg-slate-900 p-2">
<!-- Popover content -->
</div>
</div>
{/snippet}
</Popover.Content>
</Popover.Root>
```
The snippet receives:
- `wrapperProps` — spread onto the outermost positioning wrapper
- `props` — spread onto the actual UI element
- `open` — current open state (available but not needed for conditional rendering)
⚠️ **Do NOT wrap floating content in `{#if open}`** — bits-ui's `PresenceManager` handles visibility internally. Premature DOM removal via `{#if open}` breaks the animation lifecycle and causes `ScrollLock` to persist indefinitely (`document.body.style.pointerEvents = "none"` never gets cleared). See [Anti-Patterns](#anti-patterns--pitfalls).
## Tailwind CSS v4 Integration
Bits UI works naturally with the project's Tailwind v4 setup (see [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md)).
### Passing Classes
Pass `class` directly to any Bits UI component — they forward it to the root DOM element:
```svelte
<Dialog.Content class="fixed inset-0 z-50 flex items-center justify-center">
```
### Data-Attribute Selectors
Bits UI components expose `data-*` attributes on their root elements for state-driven styling:
| Attribute | Applies When |
| ------------------------- | ---------------------------------- |
| `[data-state="open"]` | Component is open/expanded |
| `[data-state="closed"]` | Component is closed/collapsed |
| `[data-state="active"]` | Tab or item is active |
| `[data-state="inactive"]` | Tab or item is inactive |
| `[data-disabled]` | Component or item is disabled |
| `[data-highlighted]` | Item is highlighted (keyboard nav) |
| `[data-selected]` | Item is selected |
Example usage in a CSS block:
```css
[data-state='open'] {
--tw-ring-color: var(--color-cyan-500);
}
```
### CSS Variables for Layout
Bits UI exposes CSS variables for measuring dynamic content:
| Variable | Component |
| --------------------------------- | ------------------- |
| `--bits-accordion-content-height` | `Accordion.Content` |
| `--bits-select-anchor-width` | `Select.Content` |
| `--bits-tooltip-trigger-width` | `Tooltip` |
| `--bits-popover-anchor-width` | `Popover.Content` |
### Animations
Use `data-starting-style` and `data-ending-style` transient attributes for CSS-based enter/exit animations. These attributes are present only during the animation frame and are automatically removed.
## Project-Specific Integration
This project's technology choices align well with Bits UI:
| Project Trait | Bits UI Compatibility |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Tailwind CSS v4** (CSS-first config) | `class` prop pattern works directly — no `tailwind.config.js` required |
| **Svelte 5 runes only** (see [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md)) | Natively compatible — Bits UI is built on runes |
| **No `tailwind.config.js`** | Bits UI does not require one |
| **Colors follow brand palette** | Use existing design tokens (see [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md)) |
| **Lucide icons** (see [`icons.instructions.md`](.github/instructions/icons.instructions.md)) | Pass icon components as snippets to Bits UI components |
### Icon Pattern with Bits UI
Render Lucide SVG strings inside child snippets:
```svelte
<Accordion.Trigger>
{#snippet child({ props })}
<button {...props} class="flex items-center gap-2">
<!-- svelte-ignore no-at-html-tags -->
<!-- svelte-ignore a11y_no_svg_body -->
{@html ChevronDownIcon}
<span>Section Title</span>
</button>
{/snippet}
</Accordion.Trigger>
```
### `{@html}` for Inline SVGs — Last-Resort Pattern
The use of `{@html}` to render inline SVG strings is a **last-resort pattern** in this project. Follow the icon hierarchy below:
1. **`@lucide/svelte` components** — preferred for all UI icons (e.g., `<Filter size={16} />`, `<ChevronDown size={16} />`)
2. **`simple-icons`** — for brand icons (social media platforms, tech logos)
3. **Inline SVG via `{@html}`** — only for Bits UI child snippets where component pass-through isn't possible
> See [`icons.instructions.md`](.github/instructions/icons.instructions.md) for the full icon hierarchy and usage guidelines.
## Existing Component Overlap & Migration Guidance
The following custom components may have overlapping functionality with Bits UI. Migration should be **phased and deliberate** — never replace a tested, working component without a clear benefit.
| Component Type | Bits UI Equivalent | Priority | Rationale |
| ------------------- | ------------------------- | -------- | ------------------------------------------------------------------ |
| Custom modal/dialog | `Dialog` | Medium | Accessibility upgrade (focus trapping, ARIA, keyboard dismiss) |
| Tabs/accordion | `Tabs` / `Accordion` | Low | Existing implementation works; reconsider if a11y issues arise |
| Custom select | `Select` | Medium | Accessibility upgrade (keyboard nav, ARIA combobox pattern) |
| Custom button | `Button` | Low | Keep project-specific variants |
| (none) | `Popover` | **High** | New capability — useful for dropdown menus, filters, quick-actions |
| (none) | `Tooltip` | **High** | New capability — useful for icon-only buttons and truncated text |
| (none) | `Calendar` / `DatePicker` | Medium | New capability — date picking for scheduling |
| (none) | `DropdownMenu` | **High** | New capability — contextual menus |
### Migration Rules
1. **Never migrate a tested, working component** solely for library adoption — there must be a measurable benefit (accessibility, maintainability, feature gap)
2. When migrating, **keep the old component in place** during a transition period — do not break existing consumers
3. Wrap Bits UI components in project-specific wrappers if the same styling is used in many places (e.g., `AppPopover.svelte` that pre-applies project styling)
4. Run existing tests after any migration to confirm nothing is broken
## Usage Examples with Project Styling
### Dialog — Confirmation Modal
```svelte
<script lang="ts">
import { Dialog, Button } from 'bits-ui';
let open = $state(false);
</script>
<Button onmousedown={() => (open = true)} class="btn-cyan">Open Dialog</Button>
<Dialog.Root bind:open>
<Dialog.Content class="fixed inset-0 z-50 flex items-center justify-center">
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div
{...props}
class="shadow-card w-full max-w-md border-2 border-slate-700 bg-slate-900/95 p-6 backdrop-blur-md"
>
<Dialog.Header>
<Dialog.Title class="text-lg font-bold text-slate-100">Confirm Action</Dialog.Title>
<Dialog.Description class="mt-1 text-sm text-slate-400">
Are you sure you want to proceed? This action cannot be undone.
</Dialog.Description>
</Dialog.Header>
<div class="mt-4 flex justify-end gap-3">
<Button class="btn-ghost" onmousedown={() => (open = false)}>Cancel</Button>
<Button class="btn-cyan">Confirm</Button>
</div>
</div>
</div>
{/snippet}
</Dialog.Content>
</Dialog.Root>
```
### Popover — Filter Dropdown
```svelte
<script lang="ts">
import { Popover, Button } from 'bits-ui';
import { Filter } from '@lucide/svelte';
let open = $state(false);
let selectedFilter = $state('All');
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button {...props} class="btn-ghost flex items-center gap-2">
<Filter size={16} />
{selectedFilter}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content>
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div {...props} class="shadow-card w-56 border-2 border-slate-700 bg-slate-900 p-2">
<p class="mb-2 px-2 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Filter by Status
</p>
{#each ['All', 'Published', 'Draft', 'Archived'] as option}
<button
class="w-full px-2 py-1.5 text-left text-sm text-slate-200
hover:bg-slate-800 data-[highlighted]:bg-slate-800"
onmousedown={() => {
selectedFilter = option;
open = false;
}}
>
{option}
</button>
{/each}
</div>
</div>
{/snippet}
</Popover.Content>
</Popover.Root>
```
### Accordion — FAQ / Settings Sections
```svelte
<script lang="ts">
import { Accordion } from 'bits-ui';
import { ChevronDown } from '@lucide/svelte';
let value = $state<string | undefined>(undefined);
</script>
<Accordion.Root bind:value class="flex flex-col gap-2">
<Accordion.Item value="item-1" class="border-2 border-slate-700">
<Accordion.Trigger>
{#snippet child({ props, open })}
<button
{...props}
class="flex w-full items-center justify-between bg-slate-800 px-4 py-3
text-sm font-medium text-slate-200"
>
<span>Section One</span>
<span class:rotate-180={open} class="transition-transform duration-200">
<ChevronDown size={16} />
</span>
</button>
{/snippet}
</Accordion.Trigger>
<Accordion.Content>
{#snippet child({ props })}
<div {...props} class="border-t-2 border-slate-700 px-4 py-3 text-sm text-slate-400">
Content for section one.
</div>
{/snippet}
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2" class="border-2 border-slate-700">
<Accordion.Trigger>
{#snippet child({ props, open })}
<button
{...props}
class="flex w-full items-center justify-between bg-slate-800 px-4 py-3
text-sm font-medium text-slate-200"
>
<span>Section Two</span>
<span class:rotate-180={open} class="transition-transform duration-200">
<ChevronDown size={16} />
</span>
</button>
{/snippet}
</Accordion.Trigger>
<Accordion.Content>
{#snippet child({ props })}
<div {...props} class="border-t-2 border-slate-700 px-4 py-3 text-sm text-slate-400">
Content for section two.
</div>
{/snippet}
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
```
## Anti-Patterns / Pitfalls
-**Don't use Bits UI with Svelte 4** — it requires Svelte 5 runes and will not work
-**Don't use `<slot>` inside Bits UI components** — always use `{#snippet child(...)}` instead (see [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md))
-**Don't forget the two-level wrapper for floating components**`Popover`, `Tooltip`, `DropdownMenu`, `Select`, `Combobox`, `DatePicker`, and `Command` content snippets receive both `wrapperProps` and `props`
-**Don't wrap floating content in `{#if open}`** — bits-ui's `PresenceManager` handles visibility internally. Premature DOM removal via `{#if open}` prevents `AnimationsComplete.run()` from firing its completion callback, which causes `ScrollLock` to persist indefinitely (`document.body.style.pointerEvents = "none"` and `document.body.style.overflow = "hidden"` never get cleared). This was a critical bug discovered and fixed across all floating components during Batch 5.
-**Don't override internal handlers without `mergeProps`** — if you need to add your own event handlers while keeping Bits UI's internal handlers, import `mergeProps` from `"bits-ui"`
-**Don't import from deep subpaths** — always import from `"bits-ui"` (e.g., `import { Dialog } from "bits-ui"`, not `import Dialog from "bits-ui/dialog"`)
## Resources
| Resource | Link |
| ----------------------------- | -------------------------------------------------------------------- |
| Full Documentation (LLMs.txt) | [bits-ui.com/docs/llms.txt](https://bits-ui.com/docs/llms.txt) |
| GitHub Repository | [github.com/huntabyte/bits-ui](https://github.com/huntabyte/bits-ui) |
| Author | Hunter Johnston ([@huntabyte](https://github.com/huntabyte)) |