Files
koins/apps/desktop/.agents/skills/shadcn-svelte/rules/composition.md
T

6.4 KiB

Component Composition

Contents

  • Items always inside their Group component
  • Callouts use Alert
  • Empty states use Empty component
  • Toast notifications use svelte-sonner
  • Choosing between overlay components
  • Dialog, Sheet, and Drawer always need a Title
  • Card structure
  • Button has no isPending or isLoading prop
  • Tabs.Trigger must be inside Tabs.List
  • Avatar always needs Avatar.Fallback
  • Use Separator instead of raw hr or border divs
  • Use Skeleton for loading placeholders
  • Use Badge instead of custom styled spans

Items always inside their Group component

Never render items directly inside the content container.

Incorrect:

<script lang="ts">
  import * as Select from "$lib/components/ui/select";
</script>

<Select.Content>
  <Select.Item value="apple">Apple</Select.Item>
  <Select.Item value="banana">Banana</Select.Item>
</Select.Content>

Correct:

<script lang="ts">
  import * as Select from "$lib/components/ui/select";
</script>

<Select.Content>
  <Select.Group>
    <Select.Item value="apple">Apple</Select.Item>
    <Select.Item value="banana">Banana</Select.Item>
  </Select.Group>
</Select.Content>

This applies to all group-based components:

Item Group
Select.Item, Select.Label Select.Group
DropdownMenu.Item, DropdownMenu.Label, DropdownMenu.Sub DropdownMenu.Group
Menubar.Item Menubar.Group
ContextMenu.Item ContextMenu.Group
Command.Item Command.Group

Callouts use Alert

<script lang="ts">
  import * as Alert from "$lib/components/ui/alert";
</script>

<Alert.Root>
  <Alert.Title>Warning</Alert.Title>
  <Alert.Description>Something needs attention.</Alert.Description>
</Alert.Root>

Empty states use Empty component

<script lang="ts">
  import * as Empty from "$lib/components/ui/empty";
  import { Button } from "$lib/components/ui/button";
  import FolderIcon from "@lucide/svelte/icons/folder";
</script>

<Empty.Root>
  <Empty.Header>
    <Empty.Media variant="icon"><FolderIcon /></Empty.Media>
    <Empty.Title>No projects yet</Empty.Title>
    <Empty.Description
      >Get started by creating a new project.</Empty.Description
    >
  </Empty.Header>
  <Empty.Content>
    <Button>Create Project</Button>
  </Empty.Content>
</Empty.Root>

Toast notifications use svelte-sonner

<script lang="ts">
  import { toast } from "svelte-sonner";
</script>
toast.success("Changes saved.");
toast.error("Something went wrong.");
toast("File deleted.", {
  action: { label: "Undo", onClick: () => undoDelete() },
});

Mount the Toaster from your UI folder once in the app layout (see Sonner).


Choosing between overlay components

Use case Component
Focused task that requires input Dialog
Destructive action confirmation AlertDialog
Side panel with details or filters Sheet
Mobile-first bottom panel Drawer
Quick info on hover HoverCard
Small contextual content on click Popover

Dialog, Sheet, and Drawer always need a Title

Dialog.Title, Sheet.Title, Drawer.Title are required for accessibility. Use class="sr-only" if visually hidden.

<script lang="ts">
  import * as Dialog from "$lib/components/ui/dialog";
</script>

<Dialog.Content>
  <Dialog.Header>
    <Dialog.Title>Edit Profile</Dialog.Title>
    <Dialog.Description>Update your profile.</Dialog.Description>
  </Dialog.Header>
  ...
</Dialog.Content>

Card structure

Use full composition — don't dump everything into Card.Content:

<script lang="ts">
  import * as Card from "$lib/components/ui/card";
  import { Button } from "$lib/components/ui/button";
</script>

<Card.Root>
  <Card.Header>
    <Card.Title>Team Members</Card.Title>
    <Card.Description>Manage your team.</Card.Description>
  </Card.Header>
  <Card.Content>...</Card.Content>
  <Card.Footer>
    <Button>Invite</Button>
  </Card.Footer>
</Card.Root>

Button has no isPending or isLoading prop

Compose with Spinner inside Button + disabled:

<script lang="ts">
  import { Button } from "$lib/components/ui/button";
  import { Spinner } from "$lib/components/ui/spinner";
</script>

<Button disabled>
  <Spinner data-icon="inline-start" />
  Saving...
</Button>

Tabs.Trigger must be inside Tabs.List

Never render Tabs.Trigger directly inside Tabs.Root — always wrap in Tabs.List:

<script lang="ts">
  import * as Tabs from "$lib/components/ui/tabs";
  let tab = $state("account");
</script>

<Tabs.Root bind:value={tab}>
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="password">Password</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account">...</Tabs.Content>
</Tabs.Root>

Avatar always needs Avatar.Fallback

Always include Avatar.Fallback for when the image fails to load:

<script lang="ts">
  import * as Avatar from "$lib/components/ui/avatar";
</script>

<Avatar.Root>
  <Avatar.Image src="/avatar.png" alt="User" />
  <Avatar.Fallback>JD</Avatar.Fallback>
</Avatar.Root>

Use existing components instead of custom markup

Instead of Use
<hr> or <div class="border-t"> <Separator /> (import { Separator } from "$lib/components/ui/separator")
<div class="animate-pulse"> with styled divs <Skeleton class="h-4 w-3/4" /> (import { Skeleton } from "$lib/components/ui/skeleton")
<span class="rounded-full bg-green-100 ..."> <Badge variant="secondary"> (import { Badge } from "$lib/components/ui/badge")