diff --git a/apps/website/content/docs/components/dialog.mdx b/apps/website/content/docs/components/dialog.mdx
index 4d6339cc5..5f943155f 100644
--- a/apps/website/content/docs/components/dialog.mdx
+++ b/apps/website/content/docs/components/dialog.mdx
@@ -60,6 +60,14 @@ Dialog 컴포넌트의 다양한 구성 패턴입니다.
```
+## Anatomy
+
+---
+
+컴포넌트의 구조를 탐색하고 각 파트의 역할을 확인합니다. 파트 이름에 마우스를 올리면 해당 영역이 하이라이트됩니다.
+
+
+
## Props Table
---
diff --git a/apps/website/package.json b/apps/website/package.json
index 8f9c8fb66..50478e22b 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -8,6 +8,7 @@
"dev": "next dev --turbo",
"format": "prettier --write \"./src/**/*.{ts,tsx,md}\"",
"format:check": "prettier --check \"./src/**/*.{ts,tsx,md}\"",
+ "generate:anatomy": "node scripts/generate-anatomy.mjs",
"postinstall": "fumadocs-mdx",
"lint": "eslint ./src",
"start": "next start",
diff --git a/apps/website/public/components/anatomy/avatar.json b/apps/website/public/components/anatomy/avatar.json
new file mode 100644
index 000000000..813befa80
--- /dev/null
+++ b/apps/website/public/components/anatomy/avatar.json
@@ -0,0 +1,21 @@
+{
+ "componentName": "Avatar",
+ "displayNamePrefix": "Avatar",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "AvatarRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "ImagePrimitive",
+ "fullName": "AvatarImagePrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "FallbackPrimitive",
+ "fullName": "AvatarFallbackPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/breadcrumb.json b/apps/website/public/components/anatomy/breadcrumb.json
new file mode 100644
index 000000000..d99e8edcf
--- /dev/null
+++ b/apps/website/public/components/anatomy/breadcrumb.json
@@ -0,0 +1,51 @@
+{
+ "componentName": "Breadcrumb",
+ "displayNamePrefix": "Breadcrumb",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "BreadcrumbRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "BreadcrumbItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "Separator",
+ "fullName": "BreadcrumbSeparator",
+ "isPrimitive": false
+ },
+ {
+ "name": "Ellipsis",
+ "fullName": "BreadcrumbEllipsis",
+ "isPrimitive": false
+ },
+ {
+ "name": "RootPrimitive",
+ "fullName": "BreadcrumbRootPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ListPrimitive",
+ "fullName": "BreadcrumbListPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemPrimitive",
+ "fullName": "BreadcrumbItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "LinkPrimitive",
+ "fullName": "BreadcrumbLinkPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "EllipsisPrimitive",
+ "fullName": "BreadcrumbEllipsisPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/callout.json b/apps/website/public/components/anatomy/callout.json
new file mode 100644
index 000000000..98e34b2e0
--- /dev/null
+++ b/apps/website/public/components/anatomy/callout.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "Callout",
+ "displayNamePrefix": "Callout",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "CalloutRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Icon",
+ "fullName": "CalloutIcon",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/card.json b/apps/website/public/components/anatomy/card.json
new file mode 100644
index 000000000..79801f572
--- /dev/null
+++ b/apps/website/public/components/anatomy/card.json
@@ -0,0 +1,26 @@
+{
+ "componentName": "Card",
+ "displayNamePrefix": "Card",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "CardRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Header",
+ "fullName": "CardHeader",
+ "isPrimitive": false
+ },
+ {
+ "name": "Body",
+ "fullName": "CardBody",
+ "isPrimitive": false
+ },
+ {
+ "name": "Footer",
+ "fullName": "CardFooter",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/checkbox.json b/apps/website/public/components/anatomy/checkbox.json
new file mode 100644
index 000000000..295ccc03b
--- /dev/null
+++ b/apps/website/public/components/anatomy/checkbox.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "Checkbox",
+ "displayNamePrefix": "Checkbox",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "CheckboxRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "IndicatorPrimitive",
+ "fullName": "CheckboxIndicatorPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/collapsible.json b/apps/website/public/components/anatomy/collapsible.json
new file mode 100644
index 000000000..eae4e0ce4
--- /dev/null
+++ b/apps/website/public/components/anatomy/collapsible.json
@@ -0,0 +1,21 @@
+{
+ "componentName": "Collapsible",
+ "displayNamePrefix": "Collapsible",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "CollapsibleRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "CollapsibleTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Panel",
+ "fullName": "CollapsiblePanel",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/dialog.json b/apps/website/public/components/anatomy/dialog.json
new file mode 100644
index 000000000..88684538f
--- /dev/null
+++ b/apps/website/public/components/anatomy/dialog.json
@@ -0,0 +1,66 @@
+{
+ "componentName": "Dialog",
+ "displayNamePrefix": "Dialog",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "DialogRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "DialogTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "DialogPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "OverlayPrimitive",
+ "fullName": "DialogOverlayPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "DialogPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "Popup",
+ "fullName": "DialogPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Title",
+ "fullName": "DialogTitle",
+ "isPrimitive": false
+ },
+ {
+ "name": "Description",
+ "fullName": "DialogDescription",
+ "isPrimitive": false
+ },
+ {
+ "name": "Close",
+ "fullName": "DialogClose",
+ "isPrimitive": false
+ },
+ {
+ "name": "Header",
+ "fullName": "DialogHeader",
+ "isPrimitive": false
+ },
+ {
+ "name": "Body",
+ "fullName": "DialogBody",
+ "isPrimitive": false
+ },
+ {
+ "name": "Footer",
+ "fullName": "DialogFooter",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/field.json b/apps/website/public/components/anatomy/field.json
new file mode 100644
index 000000000..f2ea1c6a1
--- /dev/null
+++ b/apps/website/public/components/anatomy/field.json
@@ -0,0 +1,36 @@
+{
+ "componentName": "Field",
+ "displayNamePrefix": "Field",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "FieldRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Label",
+ "fullName": "FieldLabel",
+ "isPrimitive": false
+ },
+ {
+ "name": "Description",
+ "fullName": "FieldDescription",
+ "isPrimitive": false
+ },
+ {
+ "name": "Error",
+ "fullName": "FieldError",
+ "isPrimitive": false
+ },
+ {
+ "name": "Success",
+ "fullName": "FieldSuccess",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "FieldItem",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/floating-bar.json b/apps/website/public/components/anatomy/floating-bar.json
new file mode 100644
index 000000000..b3de990d0
--- /dev/null
+++ b/apps/website/public/components/anatomy/floating-bar.json
@@ -0,0 +1,41 @@
+{
+ "componentName": "FloatingBar",
+ "displayNamePrefix": "FloatingBar",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "FloatingBarRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "FloatingBarTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "FloatingBarPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Close",
+ "fullName": "FloatingBarClose",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "FloatingBarPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "FloatingBarPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "FloatingBarPopupPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/grid.json b/apps/website/public/components/anatomy/grid.json
new file mode 100644
index 000000000..dbbfe8aa3
--- /dev/null
+++ b/apps/website/public/components/anatomy/grid.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "Grid",
+ "displayNamePrefix": "Grid",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "GridRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "GridItem",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/input-group.json b/apps/website/public/components/anatomy/input-group.json
new file mode 100644
index 000000000..a6a0ccf2f
--- /dev/null
+++ b/apps/website/public/components/anatomy/input-group.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "InputGroup",
+ "displayNamePrefix": "InputGroup",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "InputGroupRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Counter",
+ "fullName": "InputGroupCounter",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/menu.json b/apps/website/public/components/anatomy/menu.json
new file mode 100644
index 000000000..4c235a4c5
--- /dev/null
+++ b/apps/website/public/components/anatomy/menu.json
@@ -0,0 +1,111 @@
+{
+ "componentName": "Menu",
+ "displayNamePrefix": "Menu",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "MenuRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "MenuTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "MenuPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "MenuPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "MenuPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "Popup",
+ "fullName": "MenuPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Group",
+ "fullName": "MenuGroup",
+ "isPrimitive": false
+ },
+ {
+ "name": "GroupLabel",
+ "fullName": "MenuGroupLabel",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "MenuItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "Separator",
+ "fullName": "MenuSeparator",
+ "isPrimitive": false
+ },
+ {
+ "name": "SubmenuRoot",
+ "fullName": "MenuSubmenuRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "SubmenuTriggerItem",
+ "fullName": "MenuSubmenuTriggerItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "SubmenuPopupPrimitive",
+ "fullName": "MenuSubmenuPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "SubmenuPopup",
+ "fullName": "MenuSubmenuPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "CheckboxItem",
+ "fullName": "MenuCheckboxItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "CheckboxItemPrimitive",
+ "fullName": "MenuCheckboxItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "CheckboxItemIndicatorPrimitive",
+ "fullName": "MenuCheckboxItemIndicatorPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "RadioGroup",
+ "fullName": "MenuRadioGroup",
+ "isPrimitive": false
+ },
+ {
+ "name": "RadioItem",
+ "fullName": "MenuRadioItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "RadioItemPrimitive",
+ "fullName": "MenuRadioItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "RadioItemIndicatorPrimitive",
+ "fullName": "MenuRadioItemIndicatorPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/multi-select.json b/apps/website/public/components/anatomy/multi-select.json
new file mode 100644
index 000000000..a4ee1f3bc
--- /dev/null
+++ b/apps/website/public/components/anatomy/multi-select.json
@@ -0,0 +1,86 @@
+{
+ "componentName": "MultiSelect",
+ "displayNamePrefix": "MultiSelect",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "MultiSelectRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "MultiSelectTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "MultiSelectPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Group",
+ "fullName": "MultiSelectGroup",
+ "isPrimitive": false
+ },
+ {
+ "name": "GroupLabel",
+ "fullName": "MultiSelectGroupLabel",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "MultiSelectItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "Separator",
+ "fullName": "MultiSelectSeparator",
+ "isPrimitive": false
+ },
+ {
+ "name": "ValuePrimitive",
+ "fullName": "MultiSelectValuePrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PlaceholderPrimitive",
+ "fullName": "MultiSelectPlaceholderPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TriggerPrimitive",
+ "fullName": "MultiSelectTriggerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TriggerIconPrimitive",
+ "fullName": "MultiSelectTriggerIconPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "MultiSelectPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "MultiSelectPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "MultiSelectPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemPrimitive",
+ "fullName": "MultiSelectItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemIndicatorPrimitive",
+ "fullName": "MultiSelectItemIndicatorPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/navigation-menu.json b/apps/website/public/components/anatomy/navigation-menu.json
new file mode 100644
index 000000000..9964f9e81
--- /dev/null
+++ b/apps/website/public/components/anatomy/navigation-menu.json
@@ -0,0 +1,71 @@
+{
+ "componentName": "NavigationMenu",
+ "displayNamePrefix": "NavigationMenu",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "NavigationMenuRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "List",
+ "fullName": "NavigationMenuList",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "NavigationMenuItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "Link",
+ "fullName": "NavigationMenuLink",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "NavigationMenuTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Viewport",
+ "fullName": "NavigationMenuViewport",
+ "isPrimitive": false
+ },
+ {
+ "name": "Content",
+ "fullName": "NavigationMenuContent",
+ "isPrimitive": false
+ },
+ {
+ "name": "TriggerPrimitive",
+ "fullName": "NavigationMenuTriggerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TriggerIndicatorPrimitive",
+ "fullName": "NavigationMenuTriggerIndicatorPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "NavigationMenuPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "NavigationMenuPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "NavigationMenuPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ViewportPrimitive",
+ "fullName": "NavigationMenuViewportPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/pagination.json b/apps/website/public/components/anatomy/pagination.json
new file mode 100644
index 000000000..24005884a
--- /dev/null
+++ b/apps/website/public/components/anatomy/pagination.json
@@ -0,0 +1,61 @@
+{
+ "componentName": "Pagination",
+ "displayNamePrefix": "Pagination",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "PaginationRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Items",
+ "fullName": "PaginationItems",
+ "isPrimitive": false
+ },
+ {
+ "name": "Previous",
+ "fullName": "PaginationPrevious",
+ "isPrimitive": false
+ },
+ {
+ "name": "Next",
+ "fullName": "PaginationNext",
+ "isPrimitive": false
+ },
+ {
+ "name": "RootPrimitive",
+ "fullName": "PaginationRootPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ListPrimitive",
+ "fullName": "PaginationListPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemPrimitive",
+ "fullName": "PaginationItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ButtonPrimitive",
+ "fullName": "PaginationButtonPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PreviousPrimitive",
+ "fullName": "PaginationPreviousPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "NextPrimitive",
+ "fullName": "PaginationNextPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "EllipsisPrimitive",
+ "fullName": "PaginationEllipsisPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/popover.json b/apps/website/public/components/anatomy/popover.json
new file mode 100644
index 000000000..652fe84de
--- /dev/null
+++ b/apps/website/public/components/anatomy/popover.json
@@ -0,0 +1,51 @@
+{
+ "componentName": "Popover",
+ "displayNamePrefix": "Popover",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "PopoverRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "PopoverTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "PopoverPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Title",
+ "fullName": "PopoverTitle",
+ "isPrimitive": false
+ },
+ {
+ "name": "Description",
+ "fullName": "PopoverDescription",
+ "isPrimitive": false
+ },
+ {
+ "name": "Close",
+ "fullName": "PopoverClose",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "PopoverPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "PopoverPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "PopoverPopupPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/radio-group.json b/apps/website/public/components/anatomy/radio-group.json
new file mode 100644
index 000000000..4dab94227
--- /dev/null
+++ b/apps/website/public/components/anatomy/radio-group.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "RadioGroup",
+ "displayNamePrefix": "RadioGroup",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "RadioGroupRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Label",
+ "fullName": "RadioGroupLabel",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/radio.json b/apps/website/public/components/anatomy/radio.json
new file mode 100644
index 000000000..b03cd4380
--- /dev/null
+++ b/apps/website/public/components/anatomy/radio.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "Radio",
+ "displayNamePrefix": "Radio",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "RadioRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "IndicatorPrimitive",
+ "fullName": "RadioIndicatorPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/select.json b/apps/website/public/components/anatomy/select.json
new file mode 100644
index 000000000..83c5eaafc
--- /dev/null
+++ b/apps/website/public/components/anatomy/select.json
@@ -0,0 +1,86 @@
+{
+ "componentName": "Select",
+ "displayNamePrefix": "Select",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "SelectRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "SelectTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "SelectPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Item",
+ "fullName": "SelectItem",
+ "isPrimitive": false
+ },
+ {
+ "name": "Group",
+ "fullName": "SelectGroup",
+ "isPrimitive": false
+ },
+ {
+ "name": "GroupLabel",
+ "fullName": "SelectGroupLabel",
+ "isPrimitive": false
+ },
+ {
+ "name": "Separator",
+ "fullName": "SelectSeparator",
+ "isPrimitive": false
+ },
+ {
+ "name": "ValuePrimitive",
+ "fullName": "SelectValuePrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PlaceholderPrimitive",
+ "fullName": "SelectPlaceholderPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TriggerPrimitive",
+ "fullName": "SelectTriggerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TriggerIconPrimitive",
+ "fullName": "SelectTriggerIconPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "SelectPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "SelectPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "SelectPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemPrimitive",
+ "fullName": "SelectItemPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ItemIndicatorPrimitive",
+ "fullName": "SelectItemIndicatorPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/sheet.json b/apps/website/public/components/anatomy/sheet.json
new file mode 100644
index 000000000..11df648a7
--- /dev/null
+++ b/apps/website/public/components/anatomy/sheet.json
@@ -0,0 +1,71 @@
+{
+ "componentName": "Sheet",
+ "displayNamePrefix": "Sheet",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "SheetRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "SheetTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Close",
+ "fullName": "SheetClose",
+ "isPrimitive": false
+ },
+ {
+ "name": "Title",
+ "fullName": "SheetTitle",
+ "isPrimitive": false
+ },
+ {
+ "name": "Description",
+ "fullName": "SheetDescription",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "SheetPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Header",
+ "fullName": "SheetHeader",
+ "isPrimitive": false
+ },
+ {
+ "name": "Body",
+ "fullName": "SheetBody",
+ "isPrimitive": false
+ },
+ {
+ "name": "Footer",
+ "fullName": "SheetFooter",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "SheetPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "OverlayPrimitive",
+ "fullName": "SheetOverlayPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "SheetPopupPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "SheetPositionerPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/switch.json b/apps/website/public/components/anatomy/switch.json
new file mode 100644
index 000000000..12a6cb459
--- /dev/null
+++ b/apps/website/public/components/anatomy/switch.json
@@ -0,0 +1,16 @@
+{
+ "componentName": "Switch",
+ "displayNamePrefix": "Switch",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "SwitchRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "ThumbPrimitive",
+ "fullName": "SwitchThumbPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/table.json b/apps/website/public/components/anatomy/table.json
new file mode 100644
index 000000000..263a2c0b1
--- /dev/null
+++ b/apps/website/public/components/anatomy/table.json
@@ -0,0 +1,51 @@
+{
+ "componentName": "Table",
+ "displayNamePrefix": "Table",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "TableRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Header",
+ "fullName": "TableHeader",
+ "isPrimitive": false
+ },
+ {
+ "name": "Body",
+ "fullName": "TableBody",
+ "isPrimitive": false
+ },
+ {
+ "name": "Footer",
+ "fullName": "TableFooter",
+ "isPrimitive": false
+ },
+ {
+ "name": "Heading",
+ "fullName": "TableHeading",
+ "isPrimitive": false
+ },
+ {
+ "name": "Row",
+ "fullName": "TableRow",
+ "isPrimitive": false
+ },
+ {
+ "name": "Cell",
+ "fullName": "TableCell",
+ "isPrimitive": false
+ },
+ {
+ "name": "ColumnGroup",
+ "fullName": "TableColumnGroup",
+ "isPrimitive": false
+ },
+ {
+ "name": "Column",
+ "fullName": "TableColumn",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/tabs.json b/apps/website/public/components/anatomy/tabs.json
new file mode 100644
index 000000000..af936f0b4
--- /dev/null
+++ b/apps/website/public/components/anatomy/tabs.json
@@ -0,0 +1,36 @@
+{
+ "componentName": "Tabs",
+ "displayNamePrefix": "Tabs",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "TabsRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "ListPrimitive",
+ "fullName": "TabsListPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "List",
+ "fullName": "TabsList",
+ "isPrimitive": false
+ },
+ {
+ "name": "Button",
+ "fullName": "TabsButton",
+ "isPrimitive": false
+ },
+ {
+ "name": "IndicatorPrimitive",
+ "fullName": "TabsIndicatorPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "Panel",
+ "fullName": "TabsPanel",
+ "isPrimitive": false
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/toast.json b/apps/website/public/components/anatomy/toast.json
new file mode 100644
index 000000000..2b036a7b8
--- /dev/null
+++ b/apps/website/public/components/anatomy/toast.json
@@ -0,0 +1,61 @@
+{
+ "componentName": "Toast",
+ "displayNamePrefix": "Toast",
+ "parts": [
+ {
+ "name": "Provider",
+ "fullName": "ToastProvider",
+ "isPrimitive": false
+ },
+ {
+ "name": "ProviderPrimitive",
+ "fullName": "ToastProviderPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "ToastPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ViewportPrimitive",
+ "fullName": "ToastViewportPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "RootPrimitive",
+ "fullName": "ToastRootPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ContentPrimitive",
+ "fullName": "ToastContentPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "TitlePrimitive",
+ "fullName": "ToastTitlePrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "DescriptionPrimitive",
+ "fullName": "ToastDescriptionPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ActionPrimitive",
+ "fullName": "ToastActionPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "ClosePrimitive",
+ "fullName": "ToastClosePrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "IconPrimitive",
+ "fullName": "ToastIconPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/public/components/anatomy/tooltip.json b/apps/website/public/components/anatomy/tooltip.json
new file mode 100644
index 000000000..63aa01f9c
--- /dev/null
+++ b/apps/website/public/components/anatomy/tooltip.json
@@ -0,0 +1,36 @@
+{
+ "componentName": "Tooltip",
+ "displayNamePrefix": "Tooltip",
+ "parts": [
+ {
+ "name": "Root",
+ "fullName": "TooltipRoot",
+ "isPrimitive": false
+ },
+ {
+ "name": "Trigger",
+ "fullName": "TooltipTrigger",
+ "isPrimitive": false
+ },
+ {
+ "name": "Popup",
+ "fullName": "TooltipPopup",
+ "isPrimitive": false
+ },
+ {
+ "name": "PortalPrimitive",
+ "fullName": "TooltipPortalPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PositionerPrimitive",
+ "fullName": "TooltipPositionerPrimitive",
+ "isPrimitive": true
+ },
+ {
+ "name": "PopupPrimitive",
+ "fullName": "TooltipPopupPrimitive",
+ "isPrimitive": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/website/scripts/generate-anatomy.mjs b/apps/website/scripts/generate-anatomy.mjs
new file mode 100644
index 000000000..8c8565218
--- /dev/null
+++ b/apps/website/scripts/generate-anatomy.mjs
@@ -0,0 +1,90 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const CORE_COMPONENTS_PATH = path.resolve(__dirname, '../../../packages/core/src/components');
+const OUTPUT_PATH = path.resolve(__dirname, '../public/components/anatomy');
+
+/**
+ * @param {string} filePath
+ * @returns {{ name: string, fullName: string, isPrimitive: boolean }[]}
+ */
+function parsePartsFile(filePath) {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const parts = [];
+
+ // Match export statements like: ComponentNamePartName as ShortName
+ const exportRegex = /(\w+)\s+as\s+(\w+)/g;
+ let match;
+
+ while ((match = exportRegex.exec(content)) !== null) {
+ const [, internalName, exportedName] = match;
+ const isPrimitive = exportedName.endsWith('Primitive');
+
+ parts.push({
+ name: exportedName,
+ fullName: internalName,
+ isPrimitive,
+ });
+ }
+
+ return parts;
+}
+
+/**
+ * @param {string} dirPath
+ * @returns {string}
+ */
+function getComponentNameFromPath(dirPath) {
+ const dirName = path.basename(dirPath);
+ // Convert kebab-case to PascalCase
+ return dirName
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('');
+}
+
+function generateAnatomy() {
+ // Ensure output directory exists
+ if (!fs.existsSync(OUTPUT_PATH)) {
+ fs.mkdirSync(OUTPUT_PATH, { recursive: true });
+ }
+
+ // Find all index.parts.ts files
+ const componentDirs = fs
+ .readdirSync(CORE_COMPONENTS_PATH, { withFileTypes: true })
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name);
+
+ let generatedCount = 0;
+
+ for (const dirName of componentDirs) {
+ const partsFilePath = path.join(CORE_COMPONENTS_PATH, dirName, 'index.parts.ts');
+
+ if (!fs.existsSync(partsFilePath)) {
+ continue;
+ }
+
+ const componentName = getComponentNameFromPath(dirName);
+ const parts = parsePartsFile(partsFilePath);
+
+ const anatomyData = {
+ componentName,
+ displayNamePrefix: componentName,
+ parts,
+ };
+
+ const outputFilePath = path.join(OUTPUT_PATH, `${dirName}.json`);
+ fs.writeFileSync(outputFilePath, JSON.stringify(anatomyData, null, 2));
+
+ console.log(`Generated: ${dirName}.json (${parts.length} parts)`);
+ generatedCount++;
+ }
+
+ console.log(`\nGenerated ${generatedCount} anatomy files.`);
+}
+
+generateAnatomy();
diff --git a/apps/website/src/app/preview/[component]/page.tsx b/apps/website/src/app/preview/[component]/page.tsx
index 61722a430..648fb37a7 100644
--- a/apps/website/src/app/preview/[component]/page.tsx
+++ b/apps/website/src/app/preview/[component]/page.tsx
@@ -8,6 +8,7 @@ interface PreviewPageProps {
searchParams: Promise<{
path?: string;
theme?: string;
+ explorer?: string;
}>;
}
@@ -49,10 +50,11 @@ export default async function Page({ searchParams }: PreviewPageProps) {
const resolvedSearchParams = await searchParams;
const componentPath = resolvedSearchParams.path;
const theme = resolvedSearchParams.theme || 'light';
+ const explorer = resolvedSearchParams.explorer === 'true';
if (!isValidComponentPath(componentPath)) {
return ;
}
- return ;
+ return ;
}
diff --git a/apps/website/src/app/preview/[component]/preview-wrapper.tsx b/apps/website/src/app/preview/[component]/preview-wrapper.tsx
index 2f0a0a233..9a9611dae 100644
--- a/apps/website/src/app/preview/[component]/preview-wrapper.tsx
+++ b/apps/website/src/app/preview/[component]/preview-wrapper.tsx
@@ -2,15 +2,23 @@
import * as React from 'react';
+import { HighlightOverlay } from '~/components/component-explorer/iframe/highlight-overlay';
+
import { DynamicComponent } from './dynamic-component';
interface PreviewWrapperProps {
theme: string;
componentPath?: string;
+ explorer?: boolean;
children?: React.ReactNode;
}
-export function PreviewWrapper({ theme, componentPath, children }: PreviewWrapperProps) {
+export function PreviewWrapper({
+ theme,
+ componentPath,
+ explorer = false,
+ children,
+}: PreviewWrapperProps) {
// Sync theme changes (e.g., when user switches theme while iframe is open)
React.useEffect(() => {
const root = document.documentElement;
@@ -24,7 +32,14 @@ export function PreviewWrapper({ theme, componentPath, children }: PreviewWrappe
}, [theme]);
if (componentPath) {
- return ;
+ return (
+ <>
+
+
+
+ {explorer && }
+ >
+ );
}
return <>{children}>;
diff --git a/apps/website/src/app/preview/layout.tsx b/apps/website/src/app/preview/layout.tsx
index 7096950e8..1d07c6bed 100644
--- a/apps/website/src/app/preview/layout.tsx
+++ b/apps/website/src/app/preview/layout.tsx
@@ -4,8 +4,9 @@ import type { ReactNode } from 'react';
export default function PreviewLayout({ children }: { children: ReactNode }) {
return (
-
+
+ Component Preview
-
+
{children}
diff --git a/apps/website/src/components/component-explorer/anatomy-panel.tsx b/apps/website/src/components/component-explorer/anatomy-panel.tsx
new file mode 100644
index 000000000..9a116a1d7
--- /dev/null
+++ b/apps/website/src/components/component-explorer/anatomy-panel.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+
+import { Text } from '@vapor-ui/core';
+
+import { PartButton } from './part-button';
+import type { Part } from './types';
+
+interface AnatomyPanelProps {
+ componentName: string;
+ parts: Part[];
+ hoveredPart: string | null;
+ pinnedPart: string | null;
+ onPartHover: (partName: string | null) => void;
+ onPartClick: (partName: string) => void;
+ showPrimitives?: boolean;
+ availableParts: string[] | null;
+}
+
+export function AnatomyPanel({
+ componentName,
+ parts,
+ hoveredPart,
+ pinnedPart,
+ onPartHover,
+ onPartClick,
+ showPrimitives = false,
+ availableParts,
+}: AnatomyPanelProps) {
+ const { filteredParts, mainParts, primitiveParts } = useMemo(() => {
+ let filtered = showPrimitives ? parts : parts.filter((part) => !part.isPrimitive);
+
+ if (availableParts !== null) {
+ filtered = filtered.filter((part) => availableParts.includes(part.name));
+ }
+
+ return {
+ filteredParts: filtered,
+ mainParts: filtered.filter((p) => !p.isPrimitive),
+ primitiveParts: filtered.filter((p) => p.isPrimitive),
+ };
+ }, [parts, showPrimitives, availableParts]);
+
+ const handlePartHover = useCallback((partName: string) => onPartHover(partName), [onPartHover]);
+ const handleMouseLeave = useCallback(() => onPartHover(null), [onPartHover]);
+ const handlePartClick = useCallback((partName: string) => onPartClick(partName), [onPartClick]);
+
+ return (
+
+ {/* Header */}
+
+
+
+ Parts
+
+
+ {filteredParts.length} items
+
+
+
+
+ {/* Main Parts */}
+
+ {mainParts.map((part) => (
+
+ ))}
+
+
+ {/* Primitives Section */}
+ {primitiveParts.length > 0 && (
+
+
+
+ Primitives
+
+
+
+ {primitiveParts.map((part) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/website/src/components/component-explorer/component-explorer.tsx b/apps/website/src/components/component-explorer/component-explorer.tsx
new file mode 100644
index 000000000..f2145d2b5
--- /dev/null
+++ b/apps/website/src/components/component-explorer/component-explorer.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import * as React from 'react';
+
+import { Button, Text, useTheme } from '@vapor-ui/core';
+import { ErrorCircleOutlineIcon } from '@vapor-ui/icons';
+
+import { AnatomyPanel } from './anatomy-panel';
+import type { AnatomyData, Part } from './types';
+import { useExplorerCommunication } from './use-explorer-communication';
+
+interface ComponentExplorerProps {
+ name: string;
+ componentName: string;
+}
+
+export function ComponentExplorer({ name, componentName }: ComponentExplorerProps) {
+ const iframeRef = React.useRef(null);
+ const [hoveredPart, setHoveredPart] = React.useState(null);
+ const [pinnedPart, setPinnedPart] = React.useState(null);
+ const [parts, setParts] = React.useState([]);
+ const [anatomyData, setAnatomyData] = React.useState(null);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+ const [retryCount, setRetryCount] = React.useState(0);
+ const [iframeLoaded, setIframeLoaded] = React.useState(false);
+ const { highlightPart, availableParts } = useExplorerCommunication(iframeRef);
+ const { resolvedTheme } = useTheme();
+
+ React.useEffect(() => {
+ const abortController = new AbortController();
+
+ setIsLoading(true);
+ setError(null);
+
+ fetch(`/components/anatomy/${componentName}.json`, {
+ signal: abortController.signal,
+ })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}: Failed to load anatomy data`);
+ }
+ return res.json();
+ })
+ .then((data: AnatomyData) => {
+ setAnatomyData(data);
+ setParts(data.parts);
+ setError(null);
+ })
+ .catch((err) => {
+ if (err.name !== 'AbortError') {
+ console.error(`Failed to load anatomy data for ${componentName}:`, err);
+ setError(err.message || 'Failed to load anatomy data');
+ }
+ })
+ .finally(() => {
+ if (!abortController.signal.aborted) {
+ setIsLoading(false);
+ }
+ });
+
+ return () => {
+ abortController.abort();
+ };
+ }, [componentName, retryCount]);
+
+ const handleRetry = React.useCallback(() => {
+ setRetryCount((prev) => prev + 1);
+ }, []);
+
+ React.useEffect(() => {
+ if (iframeRef.current) {
+ setIframeLoaded(false);
+ const theme = resolvedTheme || 'light';
+ iframeRef.current.src = `/preview/component?path=${encodeURIComponent(name)}&theme=${theme}&explorer=true`;
+ }
+ }, [name, resolvedTheme]);
+
+ const activePart = hoveredPart ?? pinnedPart;
+
+ React.useEffect(() => {
+ highlightPart(activePart);
+ }, [activePart, highlightPart]);
+
+ const handlePartHover = React.useCallback((partName: string | null) => {
+ setHoveredPart(partName);
+ }, []);
+
+ const handlePartClick = React.useCallback((partName: string) => {
+ setPinnedPart((prev) => (prev === partName ? null : partName));
+ }, []);
+
+ const handleIframeLoad = React.useCallback(() => {
+ setIframeLoaded(true);
+ }, []);
+
+ const displayName = anatomyData?.displayNamePrefix || componentName;
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+
+
+
+
+ Failed to load anatomy
+
+
+ {error}
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {!iframeLoaded && (
+
+
+
+
+ Loading preview…
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/website/src/components/component-explorer/iframe/highlight-overlay.tsx b/apps/website/src/components/component-explorer/iframe/highlight-overlay.tsx
new file mode 100644
index 000000000..b70b61c94
--- /dev/null
+++ b/apps/website/src/components/component-explorer/iframe/highlight-overlay.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import clsx from 'clsx';
+import { throttle } from 'lodash-es';
+
+import { EXPLORER_MESSAGES } from '../types';
+import { useHighlightReceiver } from './use-highlight-receiver';
+
+interface OverlayRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+const PADDING = 4; // space-100 = 4px
+const LABEL_HEIGHT = 28; // -top-7 = 7 * 4px
+
+export function HighlightOverlay() {
+ const { highlightedPart } = useHighlightReceiver();
+ const [overlayRect, setOverlayRect] = useState(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [displayedPart, setDisplayedPart] = useState(null);
+ const lastRectRef = useRef({ top: 0, left: 0, width: 0, height: 0 });
+
+ const findAndHighlight = useCallback(() => {
+ if (!highlightedPart) {
+ setIsVisible(false);
+ return;
+ }
+
+ const selector = `[data-part="${highlightedPart}"]`;
+ const element = document.querySelector(selector);
+
+ // OverlayPrimitive가 Trigger를 덮고 있을 때 하이라이트 비활성화 (Dialog, Sheet 등)
+ // Menu/Select/Popover는 Overlay가 없으므로 Trigger 하이라이트 유지
+ if (highlightedPart === 'Trigger') {
+ const overlay = document.querySelector('[data-part="OverlayPrimitive"]');
+ if (overlay) {
+ setIsVisible(false);
+ return;
+ }
+ }
+
+ if (element) {
+ const rect = element.getBoundingClientRect();
+ const newRect = {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ lastRectRef.current = newRect;
+ setOverlayRect(newRect);
+ setDisplayedPart(highlightedPart);
+ setIsVisible(true);
+ } else {
+ setIsVisible(false);
+ }
+ }, [highlightedPart]);
+
+ useEffect(() => {
+ findAndHighlight();
+
+ if (!highlightedPart) return;
+
+ const throttleFindAndHighlight = throttle(findAndHighlight, 100);
+
+ // Re-calculate position on scroll/resize
+ window.addEventListener('scroll', throttleFindAndHighlight, true);
+ window.addEventListener('resize', throttleFindAndHighlight);
+
+ return () => {
+ window.removeEventListener('scroll', throttleFindAndHighlight, true);
+ window.removeEventListener('resize', throttleFindAndHighlight);
+ };
+ }, [highlightedPart, findAndHighlight]);
+
+ useEffect(() => {
+ const scanAndNotify = throttle(() => {
+ const elements = document.querySelectorAll('[data-part], [data-vapor-part]');
+ const parts = Array.from(elements)
+ .map((el) => el.getAttribute('data-part') || el.getAttribute('data-vapor-part'))
+ .filter(Boolean) as string[];
+ const uniqueParts = Array.from(new Set(parts));
+
+ window.parent.postMessage(
+ {
+ type: EXPLORER_MESSAGES.AVAILABLE_PARTS,
+ payload: { parts: uniqueParts },
+ },
+ window.location.origin,
+ );
+ }, 500);
+
+ scanAndNotify();
+
+ const observer = new MutationObserver(scanAndNotify);
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['data-part', 'data-vapor-part'],
+ });
+
+ return () => {
+ observer.disconnect();
+ scanAndNotify.cancel();
+ };
+ }, []);
+
+ // Use last known rect when hiding to maintain position during fade out
+ const currentRect = overlayRect || lastRectRef.current;
+ const isLabelOverflowing = currentRect.top - PADDING < LABEL_HEIGHT;
+
+ return (
+
+
+ {displayedPart}
+
+
+ );
+}
diff --git a/apps/website/src/components/component-explorer/iframe/use-highlight-receiver.ts b/apps/website/src/components/component-explorer/iframe/use-highlight-receiver.ts
new file mode 100644
index 000000000..af5d69fb8
--- /dev/null
+++ b/apps/website/src/components/component-explorer/iframe/use-highlight-receiver.ts
@@ -0,0 +1,29 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { EXPLORER_MESSAGES, type ExplorerMessage, type HighlightPartMessage } from '../types';
+
+export function useHighlightReceiver() {
+ const [highlightedPart, setHighlightedPart] = useState(null);
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+
+ const { data } = event;
+
+ if (data.type === EXPLORER_MESSAGES.HIGHLIGHT_PART) {
+ const message = data as HighlightPartMessage;
+ setHighlightedPart(message.payload.partName);
+ } else if (data.type === EXPLORER_MESSAGES.CLEAR_HIGHLIGHT) {
+ setHighlightedPart(null);
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
+ return { highlightedPart };
+}
diff --git a/apps/website/src/components/component-explorer/index.ts b/apps/website/src/components/component-explorer/index.ts
new file mode 100644
index 000000000..f5678dc27
--- /dev/null
+++ b/apps/website/src/components/component-explorer/index.ts
@@ -0,0 +1,2 @@
+export { ComponentExplorer } from './component-explorer';
+export type { AnatomyData, Part } from './types';
diff --git a/apps/website/src/components/component-explorer/part-button.tsx b/apps/website/src/components/component-explorer/part-button.tsx
new file mode 100644
index 000000000..cbe794fdd
--- /dev/null
+++ b/apps/website/src/components/component-explorer/part-button.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { memo, useCallback } from 'react';
+
+import { Button, Text } from '@vapor-ui/core';
+import clsx from 'clsx';
+
+interface PartButtonProps {
+ partName: string;
+ displayName: string;
+ isHovered: boolean;
+ isPinned?: boolean;
+ onClick?: (partName: string) => void;
+ onMouseEnter: (partName: string) => void;
+ onMouseLeave: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+}
+
+export const PartButton = memo(function PartButton({
+ partName,
+ displayName,
+ isHovered,
+ isPinned = false,
+ onClick,
+ onMouseEnter,
+ onMouseLeave,
+ onFocus,
+ onBlur,
+}: PartButtonProps) {
+ const isActive = isHovered || isPinned;
+
+ const handleClick = useCallback(() => {
+ onClick?.(partName);
+ }, [onClick, partName]);
+
+ const handleMouseEnter = useCallback(() => {
+ onMouseEnter(partName);
+ }, [onMouseEnter, partName]);
+
+ const handleFocus = useCallback(() => {
+ if (onFocus) {
+ onFocus();
+ } else {
+ onMouseEnter(partName);
+ }
+ }, [onFocus, onMouseEnter, partName]);
+
+ const handleBlur = useCallback(() => {
+ if (onBlur) {
+ onBlur();
+ } else {
+ onMouseLeave();
+ }
+ }, [onBlur, onMouseLeave]);
+
+ return (
+
+ );
+});
diff --git a/apps/website/src/components/component-explorer/types.ts b/apps/website/src/components/component-explorer/types.ts
new file mode 100644
index 000000000..22914a2a0
--- /dev/null
+++ b/apps/website/src/components/component-explorer/types.ts
@@ -0,0 +1,37 @@
+export const EXPLORER_MESSAGES = {
+ HIGHLIGHT_PART: 'EXPLORER_HIGHLIGHT_PART',
+ CLEAR_HIGHLIGHT: 'EXPLORER_CLEAR_HIGHLIGHT',
+ AVAILABLE_PARTS: 'EXPLORER_AVAILABLE_PARTS',
+} as const;
+
+export interface HighlightPartMessage {
+ type: typeof EXPLORER_MESSAGES.HIGHLIGHT_PART;
+ payload: {
+ partName: string;
+ };
+}
+
+export interface ClearHighlightMessage {
+ type: typeof EXPLORER_MESSAGES.CLEAR_HIGHLIGHT;
+}
+
+export interface AvailablePartsMessage {
+ type: typeof EXPLORER_MESSAGES.AVAILABLE_PARTS;
+ payload: {
+ parts: string[];
+ };
+}
+
+export type ExplorerMessage = HighlightPartMessage | ClearHighlightMessage | AvailablePartsMessage;
+
+export interface Part {
+ name: string;
+ fullName: string;
+ isPrimitive: boolean;
+}
+
+export interface AnatomyData {
+ componentName: string;
+ displayNamePrefix: string;
+ parts: Part[];
+}
diff --git a/apps/website/src/components/component-explorer/use-explorer-communication.ts b/apps/website/src/components/component-explorer/use-explorer-communication.ts
new file mode 100644
index 000000000..592248f02
--- /dev/null
+++ b/apps/website/src/components/component-explorer/use-explorer-communication.ts
@@ -0,0 +1,63 @@
+'use client';
+
+import { type RefObject, useCallback, useEffect, useState } from 'react';
+
+import { type AvailablePartsMessage, EXPLORER_MESSAGES, type ExplorerMessage } from './types';
+
+export function useExplorerCommunication(iframeRef: RefObject) {
+ const [availableParts, setAvailableParts] = useState(null);
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+
+ const { data } = event;
+
+ if (data.type === EXPLORER_MESSAGES.AVAILABLE_PARTS) {
+ const message = data as AvailablePartsMessage;
+ // parts 배열이 변경되었을 때만 업데이트 (불필요한 리렌더링 방지)
+ setAvailableParts((prev) => {
+ const next = message.payload.parts;
+ if (
+ prev &&
+ prev.length === next.length &&
+ prev.every((p) => next.includes(p))
+ ) {
+ return prev;
+ }
+ return next;
+ });
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
+ const highlightPart = useCallback(
+ (partName: string | null) => {
+ const iframe = iframeRef.current;
+ if (!iframe?.contentWindow) return;
+
+ if (partName) {
+ iframe.contentWindow.postMessage(
+ {
+ type: EXPLORER_MESSAGES.HIGHLIGHT_PART,
+ payload: { partName },
+ },
+ window.location.origin,
+ );
+ } else {
+ iframe.contentWindow.postMessage(
+ {
+ type: EXPLORER_MESSAGES.CLEAR_HIGHLIGHT,
+ },
+ window.location.origin,
+ );
+ }
+ },
+ [iframeRef],
+ );
+
+ return { highlightPart, availableParts };
+}
diff --git a/apps/website/src/components/demo/examples/dialog/anatomy-dialog.tsx b/apps/website/src/components/demo/examples/dialog/anatomy-dialog.tsx
new file mode 100644
index 000000000..1d7ae7ce6
--- /dev/null
+++ b/apps/website/src/components/demo/examples/dialog/anatomy-dialog.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { Button, Dialog } from '@vapor-ui/core';
+
+export default function AnatomyDialog() {
+ return (
+
+ }>
+ Open Dialog
+
+
+
+
+
+ Dialog Title
+
+
+
+ This is the dialog body content. You can add any content here.
+
+
+
+ Close}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/website/src/mdx-components.tsx b/apps/website/src/mdx-components.tsx
index 57c23dfd8..44d656727 100644
--- a/apps/website/src/mdx-components.tsx
+++ b/apps/website/src/mdx-components.tsx
@@ -9,6 +9,7 @@ import Image from 'next/image';
import AllComponentsContainer from '~/components/all-components-container';
import { CodeBlock } from '~/components/code-block/code-block';
import ComponentsCard from '~/components/component-card/component-card';
+import { ComponentExplorer } from '~/components/component-explorer';
import { ComponentPropsTable } from '~/components/component-props-table';
import { Demo } from '~/components/demo/demo';
import FoundationSizeTabs from '~/components/foundation-size-tabs';
@@ -36,6 +37,7 @@ export const getMDXComponents = (components?: MDXComponents): MDXComponents => {
Demo,
InstallSelector,
AllComponentsContainer,
+ ComponentExplorer,
ComponentPropsTable,
ComponentsCard,
FoundationSizeTabs,