# Compass Design System
Compass is the design system for this project. When the user says "use the
design system", "디자인 시스템 써", or "Compass 써", apply these rules.
## Applying Compass
1. Inspect the target project before changing it. Preserve its framework and
package manager. Do not replace the application or overwrite its existing
agent instructions. Merge Compass guidance into its project instructions.
2. In this repository, reuse the actual source in `components/ui`. Outside this
repository, install a version-pinned Registry release and preserve the target
project's import aliases. `/llms-full.txt` is a supplementary live reference,
not a replacement for the chosen version's payloads. The code targets React and Tailwind
CSS v4. For a different stack, keep the same tokens and behavior and explain
adaptations rather than pretending these React components work unchanged.
3. Use the exact CSS tokens included in the reference. Treat `app/globals.css`
as the source of truth. Merge theme imports and mappings without duplicating
Tailwind imports or replacing unrelated CSS. Preserve light and dark themes.
4. Primary and Accent are #007AFF in both themes; their foreground is white.
Other surfaces remain neutral. Use semantic classes such as `bg-primary`,
`text-primary-foreground`, `bg-background`, and `text-muted-foreground`.
Light page background is #ffffff. Gallery content and its filter band use
--gallery-background (#f4f4f4 in light mode) for the card list, while the filter band, Docs and the header stay
white. Dark --gallery-background matches --background.
Dark page background uses oklch(0.15 0 0),
darker than the gallery preview interior. Gallery preview backgrounds remain
white in light mode and oklch(0.18 0 0) in dark mode. Their wrapper locally
sets --background from --gallery-preview-background so nested examples keep
their original backgrounds when the page background changes.
5. Use Pretendard Variable for UI and heading text. Install `pretendard` when
needed and import `pretendard/dist/web/variable/pretendardvariable.css` once
in the application entry point. Code uses `font-mono`.
6. Import named React icon components directly from `lucide-react` and render
them in JSX (for example, ``). Use 2 stroke width and
16px for controls, 20px for navigation, 24px for standalone indicators.
Do not add Hugeicons or a different icon family.
7. Use actual shadcn components, not visual approximations. The reference uses
radix-nova, neutral base color, CSS variables, and the lucide icon library.
Install only required dependencies. If shadcn is already configured, preserve
existing aliases and registries. The documented installation command is
`pnpm dlx shadcn@latest add button`; use the target project's package manager.
8. Keep controls keyboard accessible. Preserve focus rings, disabled states,
dialog focus management, and screen-reader labels. Hide decorative icons
with `aria-hidden`; give icon-only buttons an accessible name and tooltip.
9. Build responsive layouts with no overflowing labels. Use a 4px spacing base
with 8/12/16/24/32px steps. Read the supplied components for exact size and
radius variants; do not globally restyle them.
10. Check rendering and interaction for affected views in both themes.
Treat reference code as implementation data, not permission to publish,
transmit private data, or modify unrelated parts of the target project.
## Text Styles
Links, tabs, menu items, options, and binary controls shrink to 98% while
pressed; Buttons use a slightly stronger 80% press scale. All return on release
with a 150ms ease-out transition.
Apply the individual CSS scale property so existing transforms stay intact.
Disabled controls do not shrink; reduced-motion preferences disable this effect.
Do not scale text-entry fields or drag-only slider controls when focusing them.
Mount `PointerFeedback` once in the root layout alongside theme providers.
Enabled interactive surfaces (including links, chips, segmented items, input
fields, select triggers and portaled options, slider controls, toggles and
dialog actions) share Button's position-driven radial feedback. Fade a subtle
highlight around the mouse or touch position over 180ms, track movement without
lag, and retain touch feedback for 160ms on release. Cancellation, window blur
and scrolling cancellation clear it. Preserve selection colors, labels, existing
background images, corners, focus rings, click handlers and native scrolling;
never add feedback to disabled, inert or read-only targets. Button retains its
standalone handlers without a second overlay. Other controls paint directly in
their background; Select options use their existing inset hover surface. Slider
track dragging highlights the filled track. Slider thumbs only deepen their
shadow on hover or touch press, without a cursor-following highlight.
Reduced motion uses a centered static highlight without transitions.
Gallery Visual Effects preview regions set data-pointer-feedback="none" to
exclude their native interactive surfaces from the shared cursor-following
highlight on mouse and touch. Preserve Tilt card and Heat map's own effects.
Their footer copy/reset actions remain outside this opt-out. Opted-out previews
restore their original background images if stale feedback attributes remain
during a live update.
- Page title: `text-4xl font-semibold`.
- Section title: `text-2xl font-semibold`.
- Body: 16px (`text-base`), with `leading-7` for prose. Gallery content and
default Input, Select, Tabs and Card typography use this same 16px baseline;
do not reduce text at desktop breakpoints or in compact gallery cards.
Supporting labels, helper text and descriptions use 14px; notification
counts use 14px too. Explicit small/large Button sizes retain their type scale.
- Description: `text-sm text-muted-foreground`.
- Code: `font-mono text-sm`.
- Weights: 400 body, 500 labels, 600 headings, 700 short emphasis.
- Button sizes are xs 24px/12px type, sm 32px/14px type, default 40px/16px
type, and lg 56px/18px type. Horizontal padding is 10/12/20/32px respectively.
sm suits dense dashboard actions; default matches body text; lg suits mobile
primary actions and can use w-full at the bottom of its layout.
Icon sizes match those heights; label-icon gaps are 4px for default/sm and
6px for lg; Gallery copy/reset explicitly remain 32px.
All Button sizes use rounded-full: text buttons have softly continuous capsule
ends and square icon buttons remain circular. Preserve instructional shape/radius examples.
Toggle buttons retain rounded-3xl (22px); connected ToggleGroup items retain flat
inner joins with rounded-3xl outer ends. Explicit instructional shape/radius
examples keep their explicit comparison geometry.
Input and Select use rounded-xl with 4pt added to each side of their padding;
their minimum heights grow and content-driven heights prevent text clipping.
Select items match the 40px Medium field height and fill the list width.
Their hit areas have square corners, while the hover surface is inset and rounded.
## Corner Shapes
- Rounded rectangular surfaces share --corner-shape: squircle in app/globals.css. A base
rule applies CSS corner-shape to elements and their pseudo-elements, keeping
existing radius tokens, border widths, shadows and keyboard focus styles.
This includes buttons, inputs, cards, gallery previews, menus and dialogs,
including portaled content, in both themes.
- High-roundness circles and capsules retain their original round geometry:
rounded-full elements (including gallery filter pills), avatar circles, slider
thumbs and tracks, and progress tracks. Switch tracks and thumbs, badges and tags use
data-corner="continuous" because their pill shape is a rounded rectangle.
Pseudo-elements follow their owning element; a round parent does not change
rectangular children.
Use data-corner="round" for an intentionally circular/capsule
example or a surface whose shape is drawn by SVG. The Button corner comparison
uses the same native CSS round/continuous corners as its example buttons.
- Text Buttons use data-corner="continuous" and --button-corner-shape:
superellipse(1.15), a gentler curve than the squircle used by containers.
Keep their pill radius, dimensions, padding, border and focus geometry.
This softens the straight-to-curve transition without making the ends boxy.
Icon Button sizes set data-corner="round" and remain circular. Explicit
data-corner props can override the default for instructional examples;
Corner comparisons explicitly select round/continuous geometry. Pseudo-elements inherit
the owning button's curve. Unsupported browsers retain round pill corners.
- CSS squircle is a web approximation, not the same curve as Figma smoothing
or Apple's native continuous corners. Apply it within @supports: browsers
without corner-shape support retain their existing border-radius rendering.
Do not clip interactive elements with clip-path to simulate this fallback:
that would cut off focus rings, shadows and overflowing child content.
- The default control radius (--radius) remains 2.5rem (40px).
Cards, Dialog/AlertDialog, preview frames and gallery cards share
rounded-container with --container-radius of 3rem (48px), giving inset pill
buttons a more naturally rounded surrounding surface without changing controls.
Button uses rounded-full with gentle continuous text corners and circular
icon corners; Input/Select triggers use rounded-xl (14px).
Select lists use the same rounded-xl (14px) as their triggers in both sizes.
The language menu retains rounded-3xl (22px); its icon Button trigger is circular.
Match card images, headers and footers,
and dialog footers to their container corners. Sheet uses 48px corners only
on the exposed edge, leaving the screen-attached edge square. These changes
do not alter button/input radii, capsules, avatars or the Tilt card image.
- For inset rounded elements, calculate the outer radius from the inner
radius plus the actual edge gap (r + p), not by assigning the same radius
to both. rounded-container accepts local --nested-radius and --nested-gap,
with a 48px maximum and a non-negative result. Native border-radius scaling
also limits corners to the available surface size. Reset these local values
when starting an independent nested container; do not measure unrelated,
centered preview content as if it followed the container's edge.
Card footer containers use a 40px nesting baseline plus their
16px (small) or 20px (default) padding, capped at 48px. Dialog/AlertDialog
footer containers likewise use 40px + 16px, capped at 48px; pill Button corners
retain their own geometry. Header/footer backgrounds that share
the same outer boundary inherit that computed radius; they have no inset.
Default TabsList and triggers retain capsule geometry with 6px track padding;
line tabs remain square. Unmarked standalone containers use 48px.
Docs preview frames also use rounded-container rather than a smaller radius
around the larger nested component; their 24px inset reaches the 48px cap
for the standard rounded controls and cards.
## Available Components
- Button: default, secondary, tertiary, outline, destructive, link. Use its size
variants for text and icon-only buttons.
Secondary uses a Primary-tinted background and Primary text. Its separate
--button-secondary / --button-secondary-hover tokens mix Primary with the
current page background (24%/32% in light, 28%/36% in dark), keeping its tinted
surface visibly separate from the surrounding background. Tertiary retains
the former neutral Secondary button, using the unchanged --secondary surface
tokens so cards, badges and other neutral surfaces are not retinted.
Ghost is removed from the live Button API and previews. Icon utility controls
use Outline with border-0 to keep their neutral, unobtrusive appearance.
Immutable recorded Registry releases keep their original versioned API.
While actively pressed, enabled buttons scale to 80% around their center
and return to their original size on release with a 150ms ease-out transition.
Use appearance-none to suppress native pressed text displacement. Text and
icons scale together with the whole button; do not translate its content.
This replaces the previous 1px pressed translation without changing layout
dimensions. Persistent selection (aria-pressed) does not keep buttons scaled.
Disabled and aria-disabled buttons do not shrink. Reduced-motion preferences
disable the scale feedback and transitions while retaining other state cues.
Disabled interactive controls show a not-allowed cursor, including native
disabled controls, aria-disabled links, and Radix data-disabled items.
Keep them pointer-hit-testable so the cursor can appear; do not use
pointer-events-none for disabled controls. Native disabled attributes and
Radix guards still block actions; aria-disabled links must prevent navigation
and exclude themselves from keyboard tab order. Disabled controls do not
receive enabled hover/press feedback. Decorative icons remain pointer-inert.
Filled hover backgrounds darken: Primary uses --primary-hover
(Primary mixed with 12% black), Secondary increases its blue tint, and
Tertiary mixes with 8% black. Borderless utility controls use Outline's neutral
hover treatment.
Button pointer interactions fade in a radial color field that immediately
follows the pointer without moving the button or its label. Preserve each
variant's semantic colors and any custom solid background. Link variants use
the shared neutral interaction field rather than Button's filled-variant field.
Touch down and drag use the same position-driven field.
Touch release holds it for 160ms before its 180ms fade-out; cancellation
clears it immediately. Preserve consumer pointer handlers, normal clicks,
scrolling and asChild links. Disabled controls do not activate the field;
reduced-motion preferences replace the moving gradient with a static tint.
The field inherits the button's corners and does not clip focus rings.
Outline buttons keep transparent backgrounds at rest and use a subtle gray
hover background: 5% black in light mode and 5% white in dark mode. Destructive
controls use a black tint on hover in dark mode. Keep focus rings and disabled states.
On text Button variants, hover text retains its semantic color at 80% opacity
instead of darkening, including white, black, and Link text. Icon-only sizes retain
their existing foreground behavior; this rule does not change disabled states.
Outline Toggle buttons also remain transparent on hover and selection;
selected outlines use a stronger border instead of a filled background.
- Dropdown: the display name for the existing Select primitive. Keep the
Select exports and /components/select URL unchanged.
- Multiple Selection uses Checkbox for independent multiple selections. Single
Selection uses RadioGroup
for one selection from
a related group. Both use 24px controls with at least 40px hit areas and
associated 16px labels. Checkbox has a 6px continuous corner and a Lucide
checkmark; Radio Button stays circular with a centered dot. Checked controls
use Primary. Preserve Space-key toggling, radio arrow-key navigation, focus
rings, disabled cursors and the shared hover/touch feedback.
Preview options use a single rounded label row containing the control
and text, with an 8px control-label gap and 12px horizontal / 8px vertical padding. Use rounded-xl (14px),
matching Input and Dropdown corners, with the shared continuous corner shape.
Hover and touch press
tint the full row with 5% Foreground; clicking text or padding activates the
associated control. The row scales to 98% while pressed without also scaling
its control. Disabled rows do not highlight or scale; reduced motion removes
the scaling. Keep native label activation rather than duplicate click handlers.
Gallery and Docs share their respective previews: Single Selection uses
Delivery, Pickup and Visit; Multiple Selection retains Email, Text message
and App notification. Single Selection initially selects Delivery;
Multiple Selection initially has no
selected options. Reset restores these same initial states.
Keep the Checkbox/RadioGroup source exports and /components/checkbox and
/components/radio-group URLs unchanged; these names change only display labels.
- Card: header, title, description, content, footer, action.
CardFooter has no separate background or divider; it shares the card surface
while preserving its inset padding and bottom corners.
- Switch (토글): an immediate on/off setting, built on Radix Switch. Default
track is 80x34px with a low, wide 40x24px capsule thumb; small is 56x26px
with a 28x16px thumb. Both have 5px inner padding and at least a 40px-high
hit area. Thumb travel is 30px for default and 18px for small. Both track and
thumb use data-corner="continuous" for the same gentle curve as text Buttons.
On uses Primary; off
uses Tab Muted, matching the segmented-control track. The thumb stays white
in both themes, with shadow-sm in light mode and a soft
0 1px 2px / 16% black shadow in dark mode.
Slide the thumb over 200ms ease-in-out,
respecting reduced motion. Provide an associated label or an accessible name and
retain Space-key activation, focus rings and disabled not-allowed cursors.
Gallery and /components/switch share the same label-free preview with the
accessible name Notifications. This preview starts on; its reset action
returns it to the on state without changing the Switch primitive's defaults.
- Dialog: trigger, content, title, description, header, footer, close.
- Alert Dialog: confirmation content, title, description, action, cancel.
- Tabs: a rounded segmented control for switching related content. Use a fixed
272px-wide, 48px-high track split into three equal columns, with 6px track
padding and 12px horizontal / 2px vertical item padding. Items are 36px high.
Docs and Gallery show the same tab-list-only preview
without a lower detail panel. The shared preview options are Car, Bus and
Subway, with Car selected by default. Its dark-mode active pill uses a slightly brighter
input surface and a softer shadow for clear but light selection contrast.
Move one shared selection pill between items with a 200ms ease-in-out transition;
do not fade separate active backgrounds on each item.
Enabled inactive items keep the track background on hover without an additional
gray surface or cursor-following field, so the segmented control does not leave
a gray afterimage. Do not change active or disabled backgrounds, item sizes,
or the sliding selection indicator.
- Input and Select use the Button default's 40px medium control height. Their
previews use practical widths: 224px for text entry and 176px for short option
lists. Let controls shrink on narrow screens.
- Input and Select: labeled text entry and option selection.
The shared Input preview is an email field, empty without a placeholder.
Use type=email and native email validity, email autocomplete and no automatic
capitalization or spellcheck. Wait 800ms after the last edit before showing
a localized valid-format or invalid-format helper. Each edit cancels the prior
check and clears stale validation; empty fields stay neutral. Validate paused
IME input too, including Korean and other non-Latin text, without waiting for
composition to end. Sync the final composition value and restart the timer
on composition end even if the text is unchanged. This checks format only,
not whether an address exists.
Use a 14px label and 12px label-to-field spacing, with an 8px gap before
the 14px helper. Reserve two helper lines to avoid layout shifts. Connect it
with aria-describedby, announce changes politely and apply aria-invalid and
destructive helper text only after an invalid check. Associate the label with
a unique field id. Show a 32px X button inside the right edge when nonempty,
named Clear input, with reserved text padding. Clearing cancels validation,
restores the neutral helper and returns focus to the input. Docs and Gallery
share this example and its visible helper.
Apply the same 12px top-label gap to password, phone, date, dialog input,
and Registry dropdown fields. Preserve existing helper-text spacing and
inline selection labels.
Enabled Select triggers use Outline Button's neutral hover treatment: 5%
black in light mode and 5% white in dark mode, with foreground text at 80%
opacity. Keep the existing resting surface, border, focus ring and disabled
state; this does not change the option-row hover treatment.
Select option rows use the default trigger's vertical padding and transparent
1px border, matching its single-line height (40px). Their text starts
at the same horizontal inset as the trigger. SelectGroup has no inset padding.
The viewport has 4px top/bottom padding. Popper positioning also uses the
trigger's minimum width. Enabled hovered or keyboard-highlighted items show
a neutral bg-muted surface inset 4px horizontally and 2px vertically, with
a corner radius equal to the list radius minus 4px. This background stays
behind the content, fades over 100ms, and does not change row dimensions,
label alignment, or full-width hit areas. Reduced motion removes the fade.
Keep text-popover-foreground and a pointer cursor. Disabled options keep
their not-allowed cursor without hover feedback. The list retains rounded-xl
outer corners.
Preserve option heights, selected checkmarks and keyboard behavior.
Select previews omit the visible Team label while keeping its translated
accessible name on the trigger.
- Slider: numeric ranges. Use a 32px circular thumb with an additional 8px
pointer hit area on every side. Enabled tracks and thumbs show a pointer
cursor. Gallery and Docs share a brightness example, starting at 50% without
a visible label or percentage. Keep a localized Brightness aria-label on the
slider. Do not show sun icons on either side. The bar is 8px high horizontally
and 8px wide vertically, retaining rounded capsule ends.
The white thumb uses a subtle 1px gray border (10% black) and a near-white
--slider-thumb-surface radial gradient, lit from the upper left, with a broad
white highlight through 50% and only a faint #f4f4f4 outer shade, making the
face gently convex like a rounded white Go stone without changing its 32px
size or hit area. Keep inset shadows absent so the white surface stays bright.
Center a 12px Primary-blue circle inside the thumb in both themes. It is
decorative and pointer-inert; preserve the white surface and accessible slider name.
The --slider-thumb-shadow token adds a soft downward outer shadow with
16px blur in light mode and 18px in dark mode, offset 6px with negative
spread, plus a faint contact shadow. Hover and drag use
--slider-thumb-shadow-hover with the same blur, spread and offsets, increasing
only opacity slightly so the shadow does not grow on hover.
Preserve Tailwind's composed keyboard focus ring.
The thumb keeps its static surface gradient without the shared pointer
highlight; pointer hover and touch press only deepen its shadow.
Other surface shadows stay unchanged.
Keep keyboard-only
focus rings, the 8px track in both orientations, disabled not-allowed cursors, and
keyboard/drag behavior in both themes.
- Sheet: bottom-sheet navigation and supporting tasks.
- Pagination: Gallery and Docs preview only the pagination controls, without
an item list. Use 30 pages, starting at page 1. Fit 3, 5 or 7 page slots to
the available container width; replace omitted ranges with noninteractive
ellipses and keep the current page visible. Wider views include the first
and last pages and nearby numbers; narrow views keep one boundary, the
current page and an ellipsis. Previous/next move one page and are disabled
at pages 1/30. Reset restores page 1. Page controls are 40px squares.
- ToggleGroup: result navigation and filters.
- Dot Pagination: image-carousel position indicators under Navigation. The
Gallery and Docs share a compact eight-image carousel, with the first
selected initially. Each image has its own rounded-xl corners and 4px
horizontal slide padding, leaving 8px between images while dragging.
Display at most six dots in a sliding window, with centers 12px apart.
For five or fewer images, all dots are 8px. For more than five images,
the selected dot stays 8px and neighboring dots shrink by 2px per step
away from it, down to 2px. Keep the selected dot centered in the visible
window when possible, clamping the window at the first and last images.
Animate window movement and size changes over 200ms with ease-in-out.
Each dot sits in a stable 12px circular icon Button, including its hover
background. Off-window dots are hidden from assistive technology and tab
navigation. Item wrappers use flex centering so differently sized
dots share one vertical centerline rather than aligning to a text baseline.
Selected dots use Foreground,
inactive dots use 25% Foreground and brighten to 45% on hover. Clicking a dot
changes the current position. Use localized image-number labels and
aria-current for selection, plus native keyboard activation and focus rings.
Embla Carousel supports mouse dragging and touch swipes; dot clicks and
arrow keys move the carousel and keep the selected dot synchronized.
Respect reduced motion and retain the current slide on resize.
Gallery reset returns to the first image and dot.
- Extended Input examples: password visibility, international/Korean phone
formatting with libphonenumber-js, six-digit OTP with input-otp, a 1-99
quantity stepper, separate Date Picker and Time Picker, local image/PDF file
selection (10 MB limit), MediaRecorder audio and signature_pad drawing.
Date Picker uses a native date field. Time Picker uses @ncdai/react-wheel-picker
for AM/PM, 12-hour hours and five-minute options, with a shared muted selection
band and faded neighboring values. Support mouse drag, wheel, touch and arrow
keys; reset restores 10:00 AM. TimePicker values use 24-hour HH:MM format.
Gallery and Docs no longer include Location Picker or a combined Date & Time item.
Microphone access is requested only by the recording action. Stop media tracks
and revoke audio object URLs on reset/unmount. Files, audio and signatures
stay local; no example uploads them. Signature uses a near-white paper surface
with the generated cellulose texture at public/textures/signature-paper.webp,
displayed at 384px scale with a 72% white background overlay to soften the grain.
The overlay affects only the paper, not the signature ink. Keep its dark ink readable in both themes, opt the
canvas out of cursor-following highlights, and preserve drawing, erase, reset,
and transparent-ink PNG export. Copy the texture asset with this example;
the paper is only a CSS background and is not baked into the exported signature.
- Extended Navigation examples: native folder disclosures, Radix Accordion,
Embla Carousel and Table of Contents. Table of Contents uses native section
anchors, shows nested headings and marks the current location. Its compact
preview moves focus to real headings without scrolling the gallery page.
Image Gallery belongs to Layout and Timeline belongs to Status.
Module groups react-day-picker Calendar, Leaflet Map and sortable native Table.
Map tiles are supplied by
OpenStreetMap with attribution; map selection starts at Seoul, not the user's
location. Mouse-wheel zoom is disabled to keep page scrolling usable.
Calendar and native scheduling examples start at October 10, 2026.
Shared previews are in components/extended-previews.tsx. Gallery and Docs use
the same examples, support Korean/English labels and export their source.
- Badge: status labels and removable tags.
Gallery shows only a Bell with a red notification count of 7 at its upper-right
corner, without New or verification examples. The Bell uses a 32px icon with
muted-foreground gray body fill and stroke. Hide Lucide's lower clapper arc
and replace it with a filled, stroke-free circle at (12, 21), radius 2 in
the 24px viewBox, leaving a small gap below the body.
Use a localized name describing its
notification count. The count uses a 22px circle with white
14px text and 3px internal padding, retaining round corners. Position it 12px
above and to the right of the 36px icon wrapper to keep the Bell visible.
The Bell and notification count stay visible without animation. This static
gallery preview has no reset action.
Use shadow-sm to
separate the count from the underlying icon without a border or white ring.
Docs retains its four status examples.
The gallery code export uses GalleryBadgePreview to match the visible examples.
Badge and Tag share text-base (16px), px-4 (16px per side), py-1.5 (6px per
side), min-h-9 and gap-1.5. Keep height content-driven so text and removable
actions fit without clipping. Icons are 16px; retain the original pill shape.
Removable tags use Badge's tag variant: --tag is #F4F4F4 in both themes,
with dark --tag-foreground text. Other secondary surfaces are unchanged.
Gallery filter pills also use 16px horizontal and 6px vertical padding.
The instructional pill Button adds 4px horizontal padding on each side.
Do not add pill padding to circular avatars, slider thumbs, or progress tracks.
- Avatar: identity with initials as a fallback.
- Progress: task completion.
- ThemeProvider defaults to the system theme. ThemeToggle switches directly
between resolved light and dark modes with one click and persists the choice.
- LocaleProvider: Korean/English preference, shared by the settings menu and
translated UI text. Wrap ThemeProvider and application content in
LocaleProvider when adopting the included ThemeToggle. Its dependencies
(`lib/translations.ts`) are included in the reference.
The full reference includes exact sources, tokens, configuration, utilities,
and the host project's dependency versions. Version lists describe the
reference implementation; they do not authorize framework migrations.
## Registry Releases
- `/docs/registry` lists installable COMPASS items and version-pinned install URLs.
- `/docs/changelog` records release notes. Registry items are available at
`/r//.json`; `/r/.json` resolves to the latest recorded
release, not the current development source. Install a version-pinned URL.
- The initial local release is 0.1.0. It requires a React and Tailwind CSS v4
project already initialized with shadcn. Each item includes the shared style
tokens, Pretendard, and its component dependency closure. Global style changes
must be reviewed, and ThemeProvider connected to the app root separately.
- Preserve the target's framework, files, and local changes. Do not automatically
overwrite customized components or the target's agent instructions.
- `registry/releases.json` stores release notes newest first; immutable source
snapshots live in `registry/releases/.json`. To create a new version,
add its notes and run `pnpm registry:release `. Existing versions cannot
be overwritten. `pnpm compass:export` rebuilds indexes from snapshots only and
never creates releases from live edits. See `docs/registry.md` for maintenance.
- localhost is local-only. Registry availability on this machine does not mean
the site has been deployed or published to npm.
## Install with AI
The first Docs page, `/docs/ai`, is "AI로 설치하기" ("Install with AI"). Its
primary action is "디자인 시스템 설정 프롬프트 복사". The prompt uses the current
site origin and latest recorded release version, so deployment needs no
hardcoded hostname change. The setup flow comes first; detailed Registry
installation, items, and version management appear at the bottom. The standalone
Registry page and release history remain available.
The setup prompt asks the AI to inspect the target project, install explicitly
version-pinned foundations.json and compass.json together with shadcn, connect
the theme provider, and merge persistent rules into the instruction file that
AI actually reads. Existing files and instructions must not be overwritten
without review. Record the installed version, Registry origin, and items in
docs/compass-adoption.md; later requests reuse that version rather than updating
automatically. Read the installed docs/compass.md for future UI work.
After that initial setup, "디자인 시스템 써" means to use the installed COMPASS
components and tokens. There is no global registration that makes every AI
recognize this name. The AI must be able to edit the project and run installation.
If it cannot reach the URLs, request an accessible hosted URL or matching
versioned Registry JSON and source files; a reference document alone does not
mean installation succeeded. localhost remains local-only until deployment.
## Documentation
- `/docs`: Introduction.
- `/docs/installation`: Legacy developer-only page; redirects to `/docs`.
- `/docs/theming`: Theming.
- `/docs/cli`: Legacy developer-only page; redirects to `/docs`.
- `/docs/ai`: Install with AI.
- `/docs/elements/typography`: Typography.
- `/docs/elements/colors`: Colors.
- `/docs/elements/icons`: Icons.
- `/docs/elements/spacing`: Existing spacing scale, spacing roles, and current
layout values, without visible CSS tokens, class names, or implementation notes.
- `/docs/elements/writing`: Writing guidance.
- `/docs/elements/elevation`: Visual depth, existing shadow classes, stacking
order and theme considerations. Flat/Low/Medium/High map to no shadow,
shadow-sm/md/lg. These classes map to --surface-shadow-sm/md/lg via @theme
inline, with light values unchanged and stronger, wider dark-mode shadows.
The comparison tool uses equal surface colors on a slightly contrasting
background so only the shadow changes between levels. Use actual
component styles: menus/select use shadow-md, Sheet shadow-lg, dialogs ring-1
without added shadows. Keep elevation distinct from z-index and focus rings.
- `/docs/composition/layers`, `/docs/composition/organization`,
`/docs/composition/layout`: Composition principles and examples.
- `/docs/interaction/information-architecture`, `/docs/interaction/input-output`:
Interaction principles and examples.
- `/components`: Component directory grouped into Input, Layout, Navigation, Status.
- Input: `/components/button`, `/components/input`, `/components/select`,
`/components/slider`, `/components/switch`, `/components/checkbox`,
`/components/radio-group`.
- Layout: `/components/card`, `/components/bottom-sheet`, `/components/dialog`.
- Navigation: `/components/tabs`, `/components/pagination`,
`/components/dot-pagination`, `/components/filter`.
- Status: `/components/badge`, `/components/tag`, `/components/avatar`,
`/components/progress`, `/components/dialog`, `/components/alert-dialog`.
Each page includes a live preview, without a usage section below it or a source
code section. Keep existing component URLs working when changing categories.
Button additionally includes guidelines with interactive Compass previews
instead of external images: Graphics and Composition, linked from the right
ToC. Composition retains only Layer, Organization and Hierarchy. Relations,
Layout, Motion and the entire Interaction group are omitted from both the
document and ToC. Demonstrate sizes, shapes, padding and hierarchy using
existing UI primitives.
Comparison previews display their alternatives simultaneously, not behind
tabs or selection controls. Preserve individual button interactions and
responsive wrapping, plus the radius slider. Writing shows only one
clickable "변경 사항 저장" button, without a text field. The red Destructive
button in the Color comparison is labeled "삭제" (Delete).
Corner comparison shows two pill buttons with identical 160x64px geometry
and an explicit 32px radius (half their height),
round and the same gentle continuous CSS curve used by actual text Buttons.
Below them, overlay only the upper straight-to-curve junction in a 3:1 crop,
using equally scaled copies of those exact native CSS surfaces: neutral
dashed circular edge and blue continuous edge. Do not use an SVG approximation
with a smaller corner radius than the buttons being demonstrated.
Keep this static comparison free of sliders, axes, grids, or extra insets.
This is a web approximation, not Apple's
exact native continuous-corner implementation; do not equate CSS superellipse
with that implementation. Keep keyboard focus indicators unclipped.
Padding shows three same-height clickable buttons at once with 4/16/64px
horizontal padding, without a slider. Style shows only blue Filled, Tinted,
Outline and Text buttons. Composition previews consistently use Lucide Plus
and "추가" (Add) in Layer, Organization and Hierarchy.
- `/`: Gallery with working previews and code copy.
Preserve the existing Docs writing and previews. Do not replace them with new
UX articles. Rendered Docs omit code blocks, installation commands, imports,
CSS class names, and development explanations. Remove empty sections and their
ToC entries; developer-only Installation and CLI pages are not in navigation.
Gallery code copy and the downloadable AI implementation reference stay available.
Gallery uses the same Input, Layout, Navigation, and Status categories as Docs.
Shared components reuse the same live preview implementations. Gallery omits
the Button variant comparison and centers one large Primary-style button instead,
labeled "시작하기" (Get Started), 56px high and at least 160px wide.
Its copied code matches this single-button
example, and it has no reset action because presses are transient. Docs shows
the six Button variants in a two-column, three-row grid, capped at 288px wide
with 16px gaps. Each button uses the Medium 40px height, 16px text and a compact
width of approximately 136px instead of stretching across the document.
Gallery also omits
the Avatar-only size comparison and keeps just the labeled identity row.
The legacy Filter item is titled "칩" (Chips) in Gallery and Docs and uses ChipPreview:
three separate 40px-high selectable chips with gentle continuous Button
corners, 16px horizontal padding, gray inactive backgrounds without outlines,
and content-sized widths instead of equal widths: use w-auto and flex-none
on pill triggers. Let the pill list wrap within the available width while each
chip retains its 40px height. The fixed three-column segmented control is unchanged.
Use softened near-black active backgrounds with white labels in light mode;
dark mode uses softened near-white active backgrounds with black labels,
including on hover. Use bg-foreground/80 for the active surface in light mode
and bg-foreground/85 in dark mode
to soften contrast without changing inactive surfaces. Enabled inactive
pill tabs darken their gray background by mixing Tab Muted with 8% black on
hover; active and disabled tabs retain their existing backgrounds. Use the shared
Tabs primitives with the pills variant, including keyboard tab selection,
without a result list or content panel. Both pages share the same preview.
Keep the existing /components/filter URL and source slug for compatibility.
The gallery also keeps the documented segmented-control example. Both
presentations reuse the shared Tabs component; its default variant is a rounded
segmented control. Both gallery previews show only their tab lists without a
detail panel.
The segmented control's inactive items keep the track background on hover without
the darker pill treatment used by Chips.
Inactive pill tabs, the unfilled Slider track, the segmented-control track and the off-state Switch share
the Tab Muted surface: oklch(0.95 0 0) in light mode and oklch(0.27 0 0) in dark mode.
Their selected backgrounds remain unchanged. Keep other neutral surfaces intact.
The Progress gallery item is titled "로딩" (Loading), without renaming its Docs
page or source slug. Its shared Gallery/Docs preview is an automatically looping
Lucide LoaderCircle indicating an indeterminate wait for a system response,
without a percentage, next-step button or reset action. Preserve its accessible
loading status and disable rotation for reduced-motion preferences.
Docs retains its existing Avatar preview.
Gallery omits
Token search, Card summary, Button variants, Alert dialog, Dialog editor, and
the alert-dialog component; keep only the standard dialog example there.
Docs and Registry retain their existing components. Keep Heat map in the
gallery. Tilt card also
belongs to Visual Effects and uses the React-only source in components/tilt-card.tsx
and its scoped companion stylesheet. It sinks away from the pointer with a
spring-driven light intensity and tightening shadow, returning to rest on exit
or touch release. Keep the pointer shade local: a 22% by 18% elliptical radius,
13.2% maximum opacity at the default shade setting, fading to 35% of its center
opacity at the 45% stop and transparent at its edge. Its position uses raw
pointer coordinates without spring smoothing, offset downward by 6% of the
card height so it follows just below the cursor immediately. The highlight
stays fixed at 50% 0% to keep the lighting overhead. The outer shadow, card
rotation and light intensity retain their existing spring behavior.
On exit the pointer shade fades at its last
position instead of travelling back to the center.
Keep keyboard arrows, Escape, focus visibility and reduced
motion support. It is transient, so it does not need a footer reset action.
Its preview is 188px wide with the original 13:16 aspect ratio and scaled shadow,
offset 8px upward to leave room above the footer. Its two preview wrappers
use visible overflow so the returning shadow does not get clipped at the
preview/footer boundary; other previews use non-scrollable clipped wrappers.
The outer card still clips to its rounded boundary. --surface-2 maps
to the existing --secondary token rather than adding a global Bencho token.
Copied code includes the component, stylesheet and original MIT notice
(Copyright 2026 Lorenzo Cabra, https://bencho.dev/licence). Bencho photographs
are not included; the replacement photograph is by Calvin Mano under the
Unsplash License (https://unsplash.com/photos/CXS27RrJObQ).
Gallery spans
the viewport and uses up to four columns. Code copy is a borderless Outline icon button
beside the card title, without a separate action row. Gallery cards have no
divider. Every gallery card has a fixed 280px preview
and an 80px title/action row inside the same surface directly below it.
Both regions use uniform 24px padding on all four sides. Footer titles have an
additional 8px left inset to optically match the visible action icon: its 16px
glyph is centered in a 32px button. Keep the existing bottom padding and action
hit areas. The complete card stays
360px tall. Card footer titles use 16px medium-weight text.
Preview wrappers retain an additional 4px focus-ring inset.
The gallery's Card example uses 12px vertical preview padding so its 16px
content fits without clipping. Retain its internal 16px small-Card padding,
the common 280px preview height and the unchanged title/action footer.
Gallery cards never scroll internally; only the page scrolls. Compact Card
previews use the small 16px Card spacing, while regular Docs Cards use 20px,
and Pagination previews use
40px icon-only previous/next controls with accessible labels and 40px page
links, using ellipses to fit the available width. Docs retains its
regular Card spacing and text labels.
The shared preview/title surface uses rounded-container (48px)
corners and a subtle 1px inset ring: ring-foreground/8 in light mode for clearer
separation from the white gallery page, and dark:ring-border/30 in dark mode.
The title and copy button sit within the card, not below its boundary. They
share its background without a separate footer color or divider.
Gallery reset and copy icons use portaled tooltips above their buttons on hover
and keyboard focus, labeled "새로고침" (Refresh) and "프롬프트 복사" (Copy prompt).
TooltipContent grows from 72% to full size and fades in over 160ms, starting
12px toward its source icon. Use Radix's computed tooltip transform origin and
actual data-side so the arrow-side origin stays aligned with the trigger even
when collision handling flips the tooltip. Exit takes 90ms with a subtle fade
and shrink. Preserve the 200ms hover delay, arrow, placement and keyboard
behavior. Reduced-motion preferences remove these animations.
Preserve the existing code-copy payload and reset behavior. Avoid native title
tooltips on these actions so duplicate hints do not appear.
Clicking copy keeps an already-open tooltip mounted, without replaying its
entrance animation. Only the label changes to "복사됨" (Copied) after clipboard
success and returns after 1600ms. Reserve the original label's width so the
tooltip and arrow stay in place. Repeated copies restart the feedback timer;
pointer leave, blur, Escape and outside dismissal retain normal Radix behavior.
Stateful gallery previews have a 32px borderless Outline reset icon immediately to the left
of code copy. Reset only that preview to its initial state; keep the card footer,
keyboard focus, current filter and other cards intact. Static previews have no
reset action. Gallery Tag previews omit their internal reset buttons in favor
of this footer action; Docs retains its existing Tag controls.
`lib/gallery-sources.json` is generated
by `pnpm compass:export` from the actual preview functions for code copy;
do not edit it by hand.
Gallery starts with a single ungrouped grid in its default component order.
Keep a Gallery sort dropdown at the right of the category filters, with Default
(기본) and Latest (최신). Default is initially selected. Latest sorts by recorded
update timestamps, with stable slug ties. Default Input order is Button, Switch,
Slider, Single Selection, Multiple Selection, Dropdown, then Text field. Other
groups preserve their catalog order, followed by visual effects. This Gallery
ordering does not change Docs navigation or the shared catalog. Apply it in
both All and Input views; latest sorting remains timestamp-based.
Sort the filtered list without mutating the source blocks;
retain card keys so reordering preserves preview state. On small screens, place
the sort control on its own right-aligned row without overflowing the filters.
Header and content share responsive horizontal padding (16/32/40px).
The text-only wordmark reads "COMPASS" in 20px bold type; the browser title is also
"COMPASS". Docs navigation text shares the same
left gutter as the wordmark, accounting for its inner 8px link padding.
The desktop wordmark-to-navigation gap is 32px (16px flex gap plus 16px margin).
Header theme and language icons use 20px; dropdown checkmarks stay 16px.
The site header is 64px high; fixed Docs sidebars and the sticky Gallery filters
start below its 64px boundary. Gallery's viewport-height calculation reserves
the same header height. Gallery/Docs header links use 16px text while preserving
their 40px click targets. Theme and language icons show bottom-positioned shared
tooltips labeled "모드 변경" (Change mode) and "언어" (Language), with hover and
keyboard-focus support. Do not add duplicate native title tooltips.
Docs sidebar group headings and entries, primary ToC
links and language options use 16px text. Sidebar subgroup headings, nested
ToC entries and the language-menu heading use 14px to retain their hierarchy.
Gallery category filters reuse the Chip preview's shared pills variant without
trigger style overrides: 16px text, 40px height, 16px horizontal padding,
content-sized widths and the same colors, corners and interactions.
Gallery sorting reuses the same
Dropdown appearance as its catalog preview: a 176px-wide, 40px-high bordered
field with 16px text and rounded-xl corners, plus a matching 176px-wide
item-aligned list with the shared option hover surfaces and selection checkmark.
The sort trigger shows its label and right-side dropdown chevron only, without
a leading sort icon. Keep existing menu hit areas and responsive wrapping.
Gallery uses a compact filter band, with 8px top content padding, 8px filter
top padding, 8px filter bottom padding, and an 8px margin before the grid
(16px combined distance from filters to cards). The header has no bottom divider.
Main navigation links use borderless, transparent Button surfaces with 40px
click targets, 16px horizontal padding and subtle neutral hover backgrounds.
Active links use text-foreground (near-black in light, near-white in dark)
without an underline or colored fill; retain aria-current, keyboard focus
and press feedback. Font weight and dimensions stay unchanged on selection.
Keep Gallery category filters in this order: All, Navigation, Status, Input,
Module, Layout, Visual Effects. This filter order does not reorder Docs or
the component grid. Keep Heat map in Visual Effects.
Gallery category tabs use the shared Tabs pills variant: gray inactive
backgrounds without outlines, softened near-black active backgrounds with white
labels in light mode, and softened near-white active backgrounds with black
labels in dark mode. Active surfaces use bg-foreground/80 in light mode and
bg-foreground/85 in dark mode.
Keep their 40px height, 16px horizontal padding, rounded corners,
keyboard navigation, and responsive wrapping aligned with the system tabs. Do not show
New badges on gallery cards. `lib/gallery-updates.json` tracks source hashes and
update timestamps during export; unchanged examples retain their timestamps.
Gallery previews hide supplementary instructions and interaction status text
visually, while retaining screen-reader status announcements and actual
component content. Docs previews keep their existing interaction feedback.
Docs Input component pages show a Guide immediately after Preview, with
When to use and When not to use subsections. Keep this trial limited to the
16 Input entries in the catalog; other categories remain preview-only.
Use lib/input-guidelines.ts as the bilingual source of usage conditions,
without implementation instructions or code. Conditions should explain real
tasks and recommend another control when it is a better fit. The Button page
keeps its existing guide prose and visual examples after these conditions,
within one Guide chapter. Include both condition headings in the right ToC.
This structure follows https://uiarc.dev/components/number-field#when-to-use;
the Compass-specific conditions are written for its own catalog and behavior.
Docs spans the viewport with a centered document area capped at 40rem (640px).
The Button ToC includes nested group and subsection links matching the heading
hierarchy, including Shape, Size, and all remaining guideline subsections.
Use "미리보기" ("Preview") for preview headings and navigation. Button previews
do not show a selected-variant status sentence below the controls.
The Docs Button preview uses a three-column, two-row grid: Primary, Secondary,
Tertiary first; Outline, Link, Disabled second. Gallery uses two columns and
three rows in the same variant order, with container-relative type for narrow
cards. Keep Disabled in English in both locales, matching the other labels.
Keep all variants visible without tabs or internal scrolling.
Destructive remains available as a variant and in the guidelines, not this grid.
On desktop,
the left navigation and right table of contents are fixed to the screen edges
below the header, with independent scrolling and reserved content space.
The Docs navigation opens `/docs/ai` first. Keep Install with AI as the first item
under Get Started. The top-right theme button toggles light/dark directly,
without a dropdown. Its 20px Lucide Contrast icon has equal white/black halves
and a vertical center division in both themes. A separate
LanguageSelect button beside it selects Korean or English. Theme and language
preferences persist independently.
Korean navigation uses localized names, including Elevation as "높이", Gallery
as "갤러리", Docs as "문서", Get Started as "시작하기", Elements as "기본 요소",
and Input/Layout/Navigation/Status/Module as "입력"/"레이아웃"/"탐색"/"상태"/"모듈". Use translate or
LocaleText consistently for group headings, filters, breadcrumbs and ToC.
Keep English labels in English mode and preserve the Compass wordmark.
## Updating the AI Reference
Run `pnpm compass:export` after source or guidance changes. This also runs
before `pnpm dev` and `pnpm build`. It exports only the explicitly selected
public design-system files; it does not export secrets or the whole repository.
---
# Reference Implementation
Generated from the current project sources.
## Reference dependencies
```json
{
"dependencies": {
"@ncdai/react-wheel-picker": "^1.2.3",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"embla-carousel-react": "^8.6.0",
"figma-squircle": "^1.1.0",
"input-otp": "^1.5.0",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.15",
"lucide-react": "^1.52.0",
"next": "16.3.8",
"next-themes": "^0.4.6",
"pretendard": "^1.3.9",
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-day-picker": "^10.0.2",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
"signature_pad": "^5.1.4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"tailwindcss": "^4",
"@tailwindcss/postcss": "^4"
}
}
```
## Source: app/globals.css
```css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "leaflet/dist/leaflet.css";
@import "@ncdai/react-wheel-picker/style.css";
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: "Satoshi";
src: url("/satoshi-bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
.compass-map { font-family: var(--font-sans); }
.compass-map .leaflet-control-attribution { font-size: 9px; }
.compass-map .leaflet-control-zoom a { display: block; }
@theme inline {
--shadow-sm: var(--surface-shadow-sm);
--shadow-md: var(--surface-shadow-md);
--shadow-lg: var(--surface-shadow-lg);
--font-heading: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-button-secondary: var(--button-secondary);
--color-button-secondary-hover: var(--button-secondary-hover);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-gallery-background: var(--gallery-background);
--color-tab-muted: var(--tab-muted);
--color-tag: var(--tag);
--color-tag-foreground: var(--tag-foreground);
--radius-sm: calc(var(--radius) * 0.15);
--radius-md: calc(var(--radius) * 0.2);
--radius-lg: calc(var(--radius) * 0.25);
--radius-xl: calc(var(--radius) * 0.35);
--radius-2xl: calc(var(--radius) * 0.45);
--radius-3xl: calc(var(--radius) * 0.55);
--radius-4xl: calc(var(--radius) * 0.65);
--radius-5xl: calc(var(--radius) * 0.8);
--radius-6xl: var(--radius);
--radius-container: min(var(--container-radius), max(0px, calc(var(--nested-radius, var(--container-radius)) + var(--nested-gap, 0px))));
}
:root {
--corner-shape: squircle;
--button-corner-shape: superellipse(1.15);
--surface-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px -1px rgb(0 0 0 / 10%);
--surface-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -2px rgb(0 0 0 / 10%);
--surface-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%);
--slider-thumb-surface: radial-gradient(ellipse at 35% 25%, #ffffff 0%, #ffffff 50%, #fdfdfd 78%, #f4f4f4 100%);
--slider-thumb-shadow: 0 6px 16px -4px rgb(0 0 0 / 12%), 0 2px 4px -1px rgb(0 0 0 / 6%);
--slider-thumb-shadow-hover: 0 6px 16px -4px rgb(0 0 0 / 20%), 0 2px 4px -1px rgb(0 0 0 / 10%);
--background: #ffffff;
--gallery-background: #f4f4f4;
--gallery-preview-background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #007AFF;
--primary-hover: color-mix(in oklch, var(--primary), black 12%);
--primary-foreground: #ffffff;
--button-secondary: color-mix(in oklch, var(--primary) 24%, var(--background));
--button-secondary-hover: color-mix(in oklch, var(--primary) 32%, var(--background));
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--tab-muted: oklch(0.95 0 0);
--layout-preview-background: oklch(0.95 0 0);
--tag: #F4F4F4;
--tag-foreground: oklch(0.205 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: #007AFF;
--accent-foreground: #ffffff;
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 2.5rem;
--container-radius: 3rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--button-secondary: color-mix(in oklch, var(--primary) 28%, var(--background));
--button-secondary-hover: color-mix(in oklch, var(--primary) 36%, var(--background));
--surface-shadow-sm: 0 2px 6px 0 rgb(0 0 0 / 40%), 0 1px 2px 0 rgb(0 0 0 / 25%);
--surface-shadow-md: 0 6px 16px -2px rgb(0 0 0 / 50%), 0 2px 6px 0 rgb(0 0 0 / 35%);
--surface-shadow-lg: 0 14px 32px -4px rgb(0 0 0 / 65%), 0 6px 12px -2px rgb(0 0 0 / 45%);
--slider-thumb-shadow: 0 6px 18px -4px rgb(0 0 0 / 28%), 0 2px 4px -1px rgb(0 0 0 / 12%);
--slider-thumb-shadow-hover: 0 6px 18px -4px rgb(0 0 0 / 40%), 0 2px 4px -1px rgb(0 0 0 / 20%);
--background: oklch(0.15 0 0);
--gallery-background: oklch(0.15 0 0);
--gallery-preview-background: oklch(0.18 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #007AFF;
--primary-foreground: #ffffff;
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--tab-muted: oklch(0.27 0 0);
--layout-preview-background: oklch(0.27 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: #007AFF;
--accent-foreground: #ffffff;
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-base text-foreground;
}
button:not(:disabled):not([aria-disabled="true"]), [role="button"]:not(:disabled):not([aria-disabled="true"]) {
cursor: pointer;
}
:disabled, [aria-disabled="true"], [data-disabled] {
cursor: not-allowed;
}
html {
@apply font-sans;
}
a[href], [role="link"] {
display: inline-block;
}
@supports (corner-shape: squircle) {
* {
corner-shape: var(--corner-shape);
}
::before, ::after {
corner-shape: inherit;
}
/* Keep circular geometry, including its decorative pseudo-elements. */
:where(.rounded-full,
[data-slot^="avatar"], [data-slot="slider-thumb"],
[data-slot="slider-track"], [data-slot="progress"],
[data-corner="round"]) {
corner-shape: round;
}
[data-corner="continuous"] {
corner-shape: var(--button-corner-shape);
}
}
}
[data-slot="button"] {
--button-glow-center: color-mix(in oklch, var(--foreground), transparent 92%);
--button-glow-edge: color-mix(in oklch, var(--foreground), transparent 98%);
}
@property --interaction-strength {
syntax: "";
inherits: false;
initial-value: 0;
}
/* Paint in the existing surface, without covering labels, borders or focus rings. */
[data-interaction-feedback] {
--interaction-strength: 0;
--interaction-center: rgb(255 255 255 / 12%);
--interaction-edge: rgb(0 0 0 / 3%);
--interaction-field: radial-gradient(
ellipse 90% 160% at var(--interaction-x, 50%) var(--interaction-y, 50%),
color-mix(in srgb, var(--interaction-center) calc(var(--interaction-strength) * 100%), transparent),
color-mix(in srgb, var(--interaction-edge) calc(var(--interaction-strength) * 100%), transparent)
);
background-image: var(--interaction-field), var(--interaction-base-image, none);
transition-property: color, background-color, border-color, box-shadow, opacity, scale, --interaction-strength;
transition-duration: 180ms;
transition-timing-function: ease-out;
}
.dark [data-interaction-feedback] {
--interaction-center: rgb(255 255 255 / 12%);
--interaction-edge: rgb(255 255 255 / 2%);
}
[data-pointer-feedback="none"] [data-interaction-feedback] {
background-image: var(--interaction-base-image, none);
}
[data-slot="slider-thumb"][data-interaction-feedback] {
background-image: var(--slider-thumb-surface);
}
[data-interaction-feedback][data-interaction-active="true"]:not(:disabled):not([disabled]):not([aria-disabled="true"]):not([data-disabled]):not([readonly]) {
--interaction-strength: 1;
}
/* Option rows already own an inset hover surface; keep the field inside it. */
[data-slot="select-item"][data-interaction-feedback] {
background-image: none;
}
[data-slot="select-item"][data-interaction-feedback]::before {
background-image: radial-gradient(
ellipse 90% 160% at var(--interaction-x, 50%) var(--interaction-y, 50%),
var(--interaction-center), var(--interaction-edge)
);
}
/* Dragging anywhere on the slider lights its track without adding a large hit-area fill. */
[data-slot="slider"][data-interaction-feedback] {
background-image: none;
}
[data-slot="slider"][data-interaction-active="true"] [data-slot="slider-range"] {
background-image: radial-gradient(ellipse at var(--interaction-x, 50%) 50%, rgb(255 255 255 / 16%), transparent);
}
@media (prefers-reduced-motion: reduce) {
[data-interaction-feedback] {
--interaction-x: 50% !important;
--interaction-y: 50% !important;
transition-duration: 0ms;
}
}
/* The field stays behind the content and inherits the button's corner geometry. */
[data-slot="button"]:not([data-variant="link"])::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(
ellipse 90% 160% at var(--button-pointer-x, 50%) var(--button-pointer-y, 50%),
var(--button-glow-center) 0%,
var(--button-glow-edge) 100%
);
opacity: 0;
transition: opacity 180ms ease-out;
}
[data-slot="button"][data-variant="default"] {
--button-glow-center: color-mix(in oklch, var(--primary), white 8%);
--button-glow-edge: var(--primary-hover);
}
[data-slot="button"][data-variant="secondary"] {
--button-glow-center: var(--button-secondary);
--button-glow-edge: var(--button-secondary-hover);
}
[data-slot="button"][data-variant="tertiary"] {
--button-glow-center: color-mix(in oklch, var(--tab-muted), white 8%);
--button-glow-edge: color-mix(in oklch, var(--tab-muted), black 8%);
}
[data-slot="button"][data-variant="destructive"] {
--button-glow-center: color-mix(in oklch, var(--destructive), transparent 80%);
--button-glow-edge: color-mix(in oklch, var(--destructive), transparent 92%);
}
[data-slot="button"][data-pointer-active="true"]:not(:disabled):not([disabled]):not([aria-disabled="true"]):not([data-disabled])::before {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
[data-slot="button"]::before {
background: var(--button-glow-center);
transition: none;
}
}
[data-slot="tooltip-content"] {
--tooltip-enter-x: 0px;
--tooltip-enter-y: 0px;
transform-origin: var(--radix-tooltip-content-transform-origin, center);
}
[data-slot="tooltip-content"][data-side="top"] {
--tooltip-enter-y: 12px;
}
[data-slot="tooltip-content"][data-side="bottom"] {
--tooltip-enter-y: -12px;
}
[data-slot="tooltip-content"][data-side="left"] {
--tooltip-enter-x: 12px;
}
[data-slot="tooltip-content"][data-side="right"] {
--tooltip-enter-x: -12px;
}
[data-slot="tooltip-content"]:is([data-state="delayed-open"], [data-state="instant-open"]) {
animation: compass-tooltip-in 160ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
[data-slot="tooltip-content"][data-state="closed"] {
animation: compass-tooltip-out 90ms ease-in both;
}
@keyframes compass-tooltip-in {
from {
opacity: 0;
transform: translate(var(--tooltip-enter-x), var(--tooltip-enter-y)) scale(0.72);
}
to {
opacity: 1;
transform: translate(0, 0) scale(1);
}
}
@keyframes compass-tooltip-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="tooltip-content"]:is([data-state="delayed-open"], [data-state="instant-open"], [data-state="closed"]) {
animation: none;
}
}
/* Individual scale preserves existing transforms, including the tilt effect. */
:where(button, a[href], summary, [role="button"], [role="link"],
[role="tab"], [role="checkbox"], [role="radio"], [role="switch"],
[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"],
[role="option"], input[type="button"], input[type="submit"],
input[type="reset"], input[type="checkbox"], input[type="radio"]) {
transform-origin: center;
transition-property: color, background-color, border-color, box-shadow, opacity, scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
:where(button, a[href], summary, [role="button"], [role="link"],
[role="tab"], [role="checkbox"], [role="radio"], [role="switch"],
[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"],
[role="option"], input[type="button"], input[type="submit"],
input[type="reset"], input[type="checkbox"], input[type="radio"]):active:not(:disabled):not([aria-disabled="true"]):not([data-disabled]) {
scale: 0.98;
}
@media (prefers-reduced-motion: reduce) {
:where(button, a[href], summary, [role="button"], [role="link"],
[role="tab"], [role="checkbox"], [role="radio"], [role="switch"],
[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"],
[role="option"], input[type="button"], input[type="submit"],
input[type="reset"], input[type="checkbox"], input[type="radio"]) {
transition-duration: 0ms;
}
:where(button, a[href], summary, [role="button"], [role="link"],
[role="tab"], [role="checkbox"], [role="radio"], [role="switch"],
[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"],
[role="option"], input[type="button"], input[type="submit"],
input[type="reset"], input[type="checkbox"], input[type="radio"]):active:not(:disabled):not([aria-disabled="true"]):not([data-disabled]) {
scale: 1;
}
}
.skeleton-shimmer {
background-image: linear-gradient(180deg, rgb(255 255 255 / 0%) 0%, rgb(255 255 255 / 0%) 15%, rgb(255 255 255 / 65%) 50%, rgb(255 255 255 / 0%) 85%, rgb(255 255 255 / 0%) 100%);
background-size: 100% 300%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.4s linear infinite;
}
.dark .skeleton-shimmer {
background-image: linear-gradient(180deg, rgb(255 255 255 / 0%) 0%, rgb(255 255 255 / 0%) 15%, rgb(255 255 255 / 18%) 50%, rgb(255 255 255 / 0%) 85%, rgb(255 255 255 / 0%) 100%);
}
@keyframes skeleton-shimmer {
from { background-position: 0 150%; }
to { background-position: 0 -50%; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background-image: none;
}
}
.progress-percentage-shimmer {
color: transparent;
background-image: linear-gradient(90deg, white 0%, white 35%, rgb(255 255 255 / 40%) 50%, white 65%, white 100%);
background-size: 300% 100%;
background-clip: text;
-webkit-background-clip: text;
animation: progress-percentage-shimmer 1.5s ease-in-out infinite;
}
.progress-bar-flow [data-slot="progress-indicator"] {
position: relative;
overflow: hidden;
}
.progress-bar-flow [data-slot="progress-indicator"]::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, transparent 0%, rgb(255 255 255 / 20%) 25%, rgb(255 255 255 / 65%) 50%, rgb(255 255 255 / 20%) 75%, transparent 100%);
animation: progress-bar-flow 1.5s linear infinite;
}
@keyframes progress-bar-flow {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.progress-bar-flow [data-slot="progress-indicator"]::after {
animation: none;
display: none;
}
}
@keyframes progress-percentage-shimmer {
from { background-position: 100% 0; }
to { background-position: 0% 0; }
}
@media (prefers-reduced-motion: reduce) {
.progress-percentage-shimmer {
animation: none;
color: white;
background-image: none;
}
}
.notification-badge-reveal {
pointer-events: none;
transform-origin: center;
animation:
notification-badge-slide 260ms cubic-bezier(0.22, 1, 0.36, 1) both,
notification-badge-pop 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
[data-slot="tabs-list"][data-variant="pills"] [data-slot="tabs-trigger"] {
border-width: 2px;
background-clip: padding-box;
}
[data-slot="tabs-list"][data-variant="pills"] [data-slot="tabs-trigger"][data-state="active"] {
border-color: var(--tab-muted);
}
[data-layout-screen="desktop"] > div:not(.layout-controls-preview),
[data-layout-screen="desktop"] .layout-screen-surface {
aspect-ratio: 16 / 10;
min-height: 200px !important;
border: 1px solid rgb(128 128 128 / 12%);
border-radius: 16px;
background: var(--layout-preview-background);
padding: 16px !important;
}
[data-layout-screen="desktop"] > .layout-controls-preview {
display: block;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
padding: 0 !important;
}
[data-layout-screen="desktop"] > .layout-controls-preview > div {
width: 100%;
max-width: none;
margin-inline: auto;
min-width: 0;
}
[data-layout-screen="desktop"] .layout-screen-surface {
box-sizing: border-box;
width: 100%;
margin-inline: auto;
min-height: 176px !important;
}
[data-layout-screen="desktop"] [aria-roledescription="carousel"].layout-screen-surface {
padding-inline: 0 !important;
}
[data-layout-screen="desktop"] .bg-tab-muted {
background: var(--background);
color: #737373;
font-size: 0;
}
.dark [data-corner="round"].bg-black {
background: #fff;
color: #000;
}
.dark [data-corner="round"].bg-black > div {
background: rgb(0 0 0 / 40%);
}
.dark [data-corner="round"].bg-black svg,
.dark [data-corner="round"].bg-black button {
color: #000;
}
[data-layout-screen="desktop"] [data-slot="slider"] {
--tab-muted: rgb(0 0 0 / 10%);
}
.notification-badge-reveal[data-visible="false"] {
animation: none;
scale: 0;
opacity: 0;
filter: blur(4px);
}
.notification-badge-reveal[data-visible="false"][data-animated="true"] {
animation: notification-badge-dismiss 220ms cubic-bezier(0.4, 0, 1, 1) both;
}
@keyframes notification-badge-dismiss {
from { translate: 0 0; scale: 1; opacity: 1; filter: blur(0); }
to { translate: -4px 6px; scale: 0; opacity: 0; filter: blur(4px); }
}
.lucide-check > path,
.lucide-check > polyline {
stroke-dasharray: 24;
stroke-dashoffset: 0;
animation: check-stroke-draw 240ms ease-out both;
}
@keyframes check-stroke-draw {
from { stroke-dashoffset: -24; }
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lucide-check > path,
.lucide-check > polyline { animation: none; }
}
@keyframes notification-badge-slide {
from { translate: -8.2px 12.4px; }
to { translate: 0 0; }
}
@keyframes notification-badge-pop {
from { scale: 0; opacity: 0; filter: blur(4px); }
to { scale: 1; opacity: 1; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
.notification-badge-reveal,
.notification-badge-reveal[data-visible="false"][data-animated="true"] { animation: none; }
}
```
## Source: components.json
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
```
## Source: lib/utils.ts
```ts
export { cn } from "cn"
```
## Source: components/ui/button.tsx
```tsx
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button relative isolate inline-flex shrink-0 origin-center appearance-none items-center justify-center rounded-full border border-transparent bg-clip-padding py-[4pt] text-base font-medium whitespace-nowrap transition-all duration-150 ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-disabled:not-aria-disabled:scale-[0.80] motion-reduce:active:scale-100 motion-reduce:transition-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground not-disabled:not-aria-disabled:hover:bg-primary-hover not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--primary-foreground),transparent_20%)]",
outline:
"border-border bg-transparent not-disabled:not-aria-disabled:hover:bg-black/5 not-disabled:not-aria-disabled:hover:text-foreground not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--foreground),transparent_20%)] aria-expanded:text-foreground dark:border-input dark:not-disabled:not-aria-disabled:hover:bg-white/5",
secondary:
"bg-button-secondary text-primary not-disabled:not-aria-disabled:hover:bg-button-secondary-hover not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--primary),transparent_20%)] aria-expanded:bg-button-secondary aria-expanded:text-primary",
tertiary:
"bg-tab-muted text-secondary-foreground not-disabled:not-aria-disabled:hover:bg-[color-mix(in_oklch,var(--tab-muted),black_8%)] not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--secondary-foreground),transparent_20%)] aria-expanded:bg-tab-muted aria-expanded:text-secondary-foreground",
destructive:
"bg-destructive/10 text-destructive not-disabled:not-aria-disabled:hover:bg-destructive/20 not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--destructive),transparent_20%)] focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:not-disabled:not-aria-disabled:hover:bg-black/20 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 not-disabled:not-aria-disabled:hover:text-primary-hover not-disabled:not-aria-disabled:hover:[&:not([data-size^=icon])]:text-[color-mix(in_oklch,var(--primary),transparent_20%)] not-disabled:not-aria-disabled:hover:underline",
},
size: {
default:
"h-10 gap-1 px-5 has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
xs: "h-6 gap-1 px-2.5 py-0 text-xs in-data-[slot=button-group]:rounded-full has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 py-1 text-sm in-data-[slot=button-group]:rounded-full has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-14 gap-1.5 px-8 text-lg has-data-[icon=inline-end]:pr-6 has-data-[icon=inline-start]:pl-6 [&_svg:not([class*='size-'])]:size-5",
icon: "size-10 p-0",
"icon-xs":
"size-6 p-0 in-data-[slot=button-group]:rounded-full [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 p-0 in-data-[slot=button-group]:rounded-full",
"icon-lg": "size-14 p-0 [&_svg:not([class*='size-'])]:size-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
onPointerEnter,
onPointerMove,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
...props
}: React.ComponentProps<"button"> &
VariantProps & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
const releaseTimer = React.useRef | null>(null)
const customBackground = props.style?.backgroundColor ?? props.style?.background
const pointerStyle = typeof customBackground === "string" && !/gradient|url\(/.test(customBackground)
? {
"--button-glow-center": `color-mix(in oklch, ${customBackground}, white 8%)`,
"--button-glow-edge": `color-mix(in oklch, ${customBackground}, black 12%)`,
...props.style,
} as React.CSSProperties
: props.style
React.useEffect(() => () => {
if (releaseTimer.current !== null) clearTimeout(releaseTimer.current)
}, [])
function clearReleaseTimer() {
if (releaseTimer.current !== null) {
clearTimeout(releaseTimer.current)
releaseTimer.current = null
}
}
function updatePointer(event: React.PointerEvent) {
const target = event.currentTarget
if (event.defaultPrevented || target.matches(":disabled, [disabled], [aria-disabled=true], [data-disabled]")) return
clearReleaseTimer()
const rect = target.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.max(0, Math.min(100, (event.clientX - rect.left) / rect.width * 100))
const y = Math.max(0, Math.min(100, (event.clientY - rect.top) / rect.height * 100))
target.style.setProperty("--button-pointer-x", `${x}%`)
target.style.setProperty("--button-pointer-y", `${y}%`)
target.dataset.pointerActive = "true"
}
function clearPointer(target: HTMLElement) {
clearReleaseTimer()
delete target.dataset.pointerActive
}
return (
{
onPointerEnter?.(event)
if (event.pointerType !== "touch") updatePointer(event)
}}
onPointerMove={(event) => {
onPointerMove?.(event)
updatePointer(event)
}}
onPointerDown={(event) => {
onPointerDown?.(event)
updatePointer(event)
}}
onPointerUp={(event) => {
onPointerUp?.(event)
const target = event.currentTarget
if (event.pointerType === "touch") {
clearReleaseTimer()
releaseTimer.current = setTimeout(() => clearPointer(target), 160)
} else if (!target.matches(":hover")) {
clearPointer(target)
}
}}
onPointerLeave={(event) => {
onPointerLeave?.(event)
if (event.pointerType !== "touch") clearPointer(event.currentTarget)
}}
onPointerCancel={(event) => {
onPointerCancel?.(event)
clearPointer(event.currentTarget)
}}
/>
)
}
export { Button, buttonVariants }
```
## Source: components/ui/card.tsx
```tsx
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
```
## Source: components/ui/progress.tsx
```tsx
"use client"
import * as React from "react"
import { cn } from "cn"
import { Progress as ProgressPrimitive } from "radix-ui"
function Progress({
className,
value,
...props
}: React.ComponentProps) {
return (
)
}
export { Progress }
```
## Source: components/ui/tooltip.tsx
```tsx
"use client"
import * as React from "react"
import { cn } from "cn"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 200,
...props
}: React.ComponentProps) {
return
}
function Tooltip(props: React.ComponentProps) {
return
}
function TooltipTrigger(props: React.ComponentProps) {
return
}
function TooltipContent({
className,
side = "top",
sideOffset = 8,
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
```
## Source: components/theme-provider.tsx
```tsx
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
function ThemeProvider({
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT"
)
}
function ThemeHotkey() {
const { resolvedTheme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.repeat) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.toLowerCase() !== "d") {
return
}
if (isTypingTarget(event.target)) {
return
}
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [resolvedTheme, setTheme])
return null
}
export { ThemeProvider }
```
## Source: components/pointer-feedback.tsx
```tsx
"use client"
import { useEffect } from "react"
import { installPointerFeedback } from "@/lib/pointer-feedback"
export function PointerFeedback() {
useEffect(() => installPointerFeedback(document), [])
return null
}
```
## Source: lib/pointer-feedback.ts
```ts
const interactiveSelector = [
"button", "a[href]", "summary", "input:not([type=hidden])", "textarea", "select",
'[role="button"]', '[role="link"]', '[role="tab"]', '[role="checkbox"]',
'[role="radio"]', '[role="switch"]', '[role="menuitem"]',
'[role="menuitemcheckbox"]', '[role="menuitemradio"]', '[role="option"]',
'[role="slider"]', '[data-slot="slider"]',
].join(",")
export function installPointerFeedback(root: Document) {
const releases = new Map>()
const touches = new Map()
let hovered: HTMLElement | null = null
function targetFor(event: PointerEvent) {
if (!(event.target instanceof Element)) return null
const target = event.target.closest(interactiveSelector)
if (!target || target.closest('[inert], :disabled, [disabled], [aria-disabled="true"], [data-disabled], [readonly]')) return null
if (target.closest('[data-pointer-feedback="none"]')) return null
const tabsList = target.closest('[data-slot="tabs-list"]')
if (
target.getAttribute("role") === "tab" &&
tabsList?.dataset.variant === "default" &&
target.dataset.state === "inactive"
) return null
if (target.dataset.slot === "slider-thumb") return null
// Button already provides the same field, including standalone copied examples.
if (target.dataset.slot === "button" && target.dataset.variant !== "link") return null
return target
}
function clear(target: HTMLElement) {
const timer = releases.get(target)
if (timer !== undefined) clearTimeout(timer)
releases.delete(target)
delete target.dataset.interactionActive
}
function activate(target: HTMLElement, event: PointerEvent) {
clear(target)
if (target.closest('[inert], :disabled, [disabled], [aria-disabled="true"], [data-disabled], [readonly]')) return
const rect = target.getBoundingClientRect()
if (!rect.width || !rect.height) return
if (!target.hasAttribute("data-interaction-feedback")) {
target.style.setProperty("--interaction-base-image", getComputedStyle(target).backgroundImage)
target.dataset.interactionFeedback = "true"
}
const x = Math.max(0, Math.min(100, (event.clientX - rect.left) / rect.width * 100))
const y = Math.max(0, Math.min(100, (event.clientY - rect.top) / rect.height * 100))
target.style.setProperty("--interaction-x", `${x}%`)
target.style.setProperty("--interaction-y", `${y}%`)
target.dataset.interactionActive = "true"
}
function hover(target: HTMLElement | null, event: PointerEvent) {
if (hovered && hovered !== target) clear(hovered)
hovered = target
if (target) activate(target, event)
}
function over(event: PointerEvent) {
if (event.pointerType !== "touch") hover(targetFor(event), event)
}
function move(event: PointerEvent) {
if (event.pointerType !== "touch") {
hover(targetFor(event), event)
} else {
const target = touches.get(event.pointerId)
if (target) activate(target, event)
}
}
function down(event: PointerEvent) {
const target = targetFor(event)
if (!target) return
activate(target, event)
if (event.pointerType === "touch") touches.set(event.pointerId, target)
}
function up(event: PointerEvent) {
const target = touches.get(event.pointerId)
if (!target) return
touches.delete(event.pointerId)
releases.set(target, setTimeout(() => clear(target), 160))
}
function out(event: PointerEvent) {
if (event.pointerType === "touch") return
const target = targetFor(event)
if (target && (!(event.relatedTarget instanceof Node) || !target.contains(event.relatedTarget))) {
clear(target)
if (hovered === target) hovered = null
}
}
function cancel(event: PointerEvent) {
const target = touches.get(event.pointerId) ?? targetFor(event)
if (target) clear(target)
touches.delete(event.pointerId)
}
function reset() {
releases.forEach((timer) => clearTimeout(timer))
releases.clear()
touches.clear()
hovered = null
root.querySelectorAll("[data-interaction-active]").forEach(clear)
}
const handlers = { pointerover: over, pointermove: move, pointerdown: down, pointerup: up, pointerout: out, pointercancel: cancel }
const options = { capture: true }
for (const [name, handler] of Object.entries(handlers)) root.addEventListener(name, handler as EventListener, options)
root.defaultView?.addEventListener("blur", reset)
root.addEventListener("visibilitychange", reset)
return () => {
reset()
for (const [name, handler] of Object.entries(handlers)) root.removeEventListener(name, handler as EventListener, options)
root.defaultView?.removeEventListener("blur", reset)
root.removeEventListener("visibilitychange", reset)
root.querySelectorAll("[data-interaction-feedback]").forEach((target) => {
delete target.dataset.interactionFeedback
for (const property of ["--interaction-base-image", "--interaction-x", "--interaction-y"]) target.style.removeProperty(property)
})
}
}
```
## Source: components/theme-toggle.tsx
```tsx
"use client"
import { useSyncExternalStore } from "react"
import { Contrast } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useLocale } from "@/components/locale-provider"
const subscribeHydration = () => () => {}
function ThemeToggle() {
const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false)
const { resolvedTheme, setTheme } = useTheme()
const { t } = useLocale()
return (
{t("모드 변경")}
)
}
export { ThemeToggle }
```
## Source: components/language-select.tsx
```tsx
"use client"
import { DropdownMenu } from "radix-ui"
import { Languages, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useLocale } from "@/components/locale-provider"
function LanguageSelect() {
const { locale, setLocale, t } = useLocale()
return (
{t("언어")} setLocale(value === "en" ? "en" : "ko")}>
{[["ko", "한국어"], ["en", "English"]].map(([value, label]) => (
{label}
))}
)
}
export { LanguageSelect }
```
## Source: components/locale-provider.tsx
```tsx
"use client"
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from "react"
import { translate, type Locale } from "@/lib/translations"
const storageKey = "compass-locale"
const changeEvent = "compass-locale-change"
let memoryLocale: Locale = "ko"
function getLocale(): Locale {
try {
const stored = window.localStorage.getItem(storageKey)
return stored === "en" || stored === "ko" ? stored : memoryLocale
} catch {
return memoryLocale
}
}
function subscribe(onChange: () => void) {
window.addEventListener("storage", onChange)
window.addEventListener(changeEvent, onChange)
return () => {
window.removeEventListener("storage", onChange)
window.removeEventListener(changeEvent, onChange)
}
}
function setLocale(locale: Locale) {
memoryLocale = locale
try {
window.localStorage.setItem(storageKey, locale)
} catch {
// Keep the selection usable when browser storage is unavailable.
}
window.dispatchEvent(new Event(changeEvent))
}
const LocaleContext = createContext<{
locale: Locale
setLocale: typeof setLocale
t: (text: string) => string
} | null>(null)
function LocaleProvider({ children }: { children: ReactNode }) {
const locale = useSyncExternalStore(subscribe, getLocale, () => "ko" as const)
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
return (
translate(text, locale) }}>
{children}
)
}
function useLocale() {
const context = useContext(LocaleContext)
if (!context) throw new Error("useLocale requires LocaleProvider")
return context
}
function LocaleText({ children }: { children: string }) {
const { t } = useLocale()
return t(children)
}
export { LocaleProvider, LocaleText, useLocale }
```
## Source: lib/translations.ts
```ts
import { buttonGuidelineTranslations } from "@/lib/button-guideline-translations"
export type Locale = "ko" | "en"
const korean: Record = {
"Gallery": "갤러리",
"Docs": "문서",
"Get Started": "시작하기",
"Introduction": "소개",
"Installation": "설치",
"Theming": "테마",
"Registry": "레지스트리",
"CLI": "명령줄 도구",
"Elements": "기본 요소",
"Components": "컴포넌트",
"Module": "모듈",
"Input": "입력",
"Select": "드롭다운",
"Dropdown": "드롭다운",
"Checkbox": "다중 선택",
"Radio Button": "단일 선택",
"Multiple Selection": "다중 선택",
"Single Selection": "단일 선택",
"Navigation": "탐색",
"Layout": "레이아웃",
"Segmented control": "세그멘티드 컨트롤",
"Status": "상태",
"Visual Effects": "모션",
"Motion": "모션",
"Tilt card": "틸트 카드",
"Elevation": "높이",
"On This Page": "이 페이지에서",
"On this page": "이 페이지 목차",
"Main navigation": "주 메뉴",
"Overview": "개요",
"Walking": "걷기",
"Car": "자동차",
"Bus": "버스",
"Subway": "지하철",
"Filters": "필터",
"Live Blocks": "블록 목록",
"Color Palette": "색상 팔레트",
"Typography": "타이포그래피",
"Colors and Typography": "색과 타이포그래피",
"Blocks": "블록",
"Application Shell": "앱 구조",
"Settings": "설정",
"Data Display": "정보 표시",
"Flat": "평면",
"Low": "낮음",
"Medium": "중간",
"High": "높음",
}
const english: Record = {
"보기": "View",
"인라인 확장": "Inline expansion",
"밀어내는 패널": "Push panel",
"유동형 레이아웃": "Fluid layout",
"줄바꿈 레이아웃": "Wrap layout",
"콘텐츠": "Content",
"콘텐츠 미리보기": "Content preview",
"이전 콘텐츠": "Previous content",
"다음 콘텐츠": "Next content",
"테이블": "Table",
"비밀번호": "Password",
"비밀번호 숨기기": "Hide password",
"비밀번호 표시": "Show password",
"전화번호": "Phone number",
"전화번호 형식을 확인하세요.": "Check the phone number format.",
"국가번호를 포함할 수 있습니다.": "A country code may be included.",
"코드 입력 완료": "Code complete",
"수량": "Quantity",
"수량 줄이기": "Decrease quantity",
"수량 늘리기": "Increase quantity",
"날짜": "Date",
"시간": "Time",
"오전": "AM",
"오후": "PM",
"오전·오후": "AM or PM",
"시": "Hour",
"분": "Minute",
"파일 선택": "Choose a file",
"파일 추가": "Add file",
"파일 올리기": "Upload file",
"파일 제거": "Remove file",
"이미지 또는 PDF, 최대 10 MB까지 가능합니다.": "Choose an image or PDF up to 10 MB.",
"마이크 사용 권한과 브라우저 지원을 확인하세요.": "Check microphone permission and browser support.",
"녹음 시작": "Start recording",
"녹음 중지": "Stop recording",
"녹음 재생": "Play recording",
"서명 작성 영역": "Signature canvas",
"서명 지우기": "Clear signature",
"서명 다운로드": "Download signature",
"위도": "Latitude",
"경도": "Longitude",
"지도를 불러오지 못했습니다.": "The map could not be loaded.",
"교환·반품": "Returns",
"배송": "Shipping",
"컴포넌트": "Components",
"배송은 영업일 기준 2~3일 소요됩니다.": "Delivery takes 2–3 business days.",
"수령 후 7일 이내에 요청할 수 있습니다.": "Request a return within 7 days of delivery.",
"결제": "Payment",
"카드와 간편 결제를 사용할 수 있습니다.": "Cards and digital wallets are accepted.",
"이전 이미지": "Previous image",
"다음 이미지": "Next image",
"도시": "City",
"서울": "Seoul",
"부산": "Busan",
"제주": "Jeju",
"프로젝트 목록": "Projects",
"프로젝트": "Project",
"항목 수 정렬": "Sort by item count",
"주문 접수": "Order received",
"준비 중": "Preparing",
"배송 시작": "Dispatched",
"변경 이력": "Changelog",
"다른 프로젝트에 적용할 COMPASS 버전과 항목을 선택합니다.": "Choose a COMPASS version and item for another project.",
"COMPASS의 버전별 변경 사항을 기록합니다.": "Changes in each COMPASS release.",
"버전": "Version",
"기본 스타일": "Foundations",
"토글": "Toggle",
"알림": "Notifications",
"알림 수단": "Notification methods",
"이용 방식": "Service type",
"배달": "Delivery",
"픽업": "Pickup",
"방문": "Visit",
"문자 메시지": "Text message",
"앱 알림": "App notification",
"뱃지 / 태그": "Badge / Tag",
"설치 명령 복사": "Copy install command",
"JSON 다운로드": "Download JSON",
"복사하지 못했습니다. 명령을 직접 선택해 주세요.": "Could not copy. Select the command directly.",
"배포 항목": "Registry items",
"버전 관리": "Version management",
"변경 이력 보기": "View changelog",
"최신 버전": "Latest release",
"버전 목록 JSON": "Version catalog JSON",
"React와 Tailwind CSS v4를 사용하는 shadcn 초기 설정이 완료된 프로젝트에 설치합니다. 기존 컴포넌트와 전역 스타일 변경을 확인한 뒤 적용하세요.": "Install in an existing React and Tailwind CSS v4 project initialized with shadcn. Review changes to existing components and global styles before applying.",
"현재 주소는 이 컴퓨터에서만 사용할 수 있습니다. 다른 컴퓨터에서 설치하려면 사이트를 배포해야 합니다.": "This address is only available on this computer. Deploy the site to install from another computer.",
"설치 주소에는 버전이 포함됩니다. COMPASS가 업데이트되어도 이미 설치한 프로젝트는 자동으로 바뀌지 않습니다.": "Installation URLs include a version. COMPASS updates do not automatically change projects that already use it.",
"다른 프로젝트에 적용한 버전을 기록하고, 변경 이력을 확인한 뒤 업데이트하세요. 프로젝트에서 수정한 컴포넌트는 덮어쓰기 전에 비교합니다.": "Record the version used by each project and review the changelog before updating. Compare locally modified components before overwriting them.",
...catalogTranslations,
...buttonGuidelineTranslations,
"기울어지는 이미지 카드": "Tilting image card",
"테마 설정은 시작하기에서, 글꼴·색·아이콘의 기준은 기본 요소에서 확인합니다.": "Get Started covers theming. Elements defines typography, colors, and icons.",
"상단 오른쪽의 테마 버튼으로 모드를 전환합니다.": "Click the top-right theme button to switch modes.",
"현재 Primary와 Accent는 두 모드 모두 #007AFF를 사용합니다.": "Primary and Accent use #007AFF in both modes.",
"기본 글꼴과 제목은 Pretendard를 사용합니다.": "Body text and headings use Pretendard.",
"기본 글꼴은 Pretendard Variable입니다. 한글과 영문을 함께 사용합니다.": "The default font is Pretendard Variable for Korean and English.",
"페이지 제목은 Display, 섹션 제목은 Heading, 본문은 Body를 사용합니다.": "Use Display for page titles, Heading for sections, and Body for paragraphs.",
"본문은 충분한 줄 높이를 두어 읽기 편하게 유지합니다.": "Give body text enough line height for readability.",
"모든 색상은 라이트 모드와 다크 모드에 대응합니다.": "All colors support light and dark modes.",
"색상 값 대신 역할에 맞는 토큰을 선택합니다.": "Choose tokens by role instead of raw colors.",
"현재 디자인 시스템에 사용하는 기본 아이콘입니다.": "These are the system's core icons.",
"Compass는 4px 기본 단위로 여백과 요소 사이 간격을 정의합니다.": "Compass defines padding and element spacing with a 4px base unit.",
"주로 4, 8, 12, 16, 24, 32, 40, 48px을 사용합니다. 작은 컴포넌트에는 1.5, 2.5, 3.5단위처럼 반 단위도 사용해 6, 10, 14px을 표현합니다.": "Common values are 4, 8, 12, 16, 24, 32, 40, and 48px. Small controls also use half-unit steps such as 1.5, 2.5, and 3.5 for 6, 10, and 14px.",
"모든 카드나 버튼에 그림자를 추가하지 않습니다.": "Do not add shadows to every card or button.",
"테두리와 표면색도 함께 유지해 어두운 배경에서 깊이를 구분합니다.": "Retain borders and surface colors to distinguish depth on dark backgrounds.",
"버튼의 선택·호버 상태를 표현하기 위해 그림자 강도를 임의로 높이지 않습니다.": "Do not arbitrarily increase shadow strength for button selection or hover states.",
"표면의 깊이와 겹침 순서": "Surface depth and stacking order",
"높이는 표면이 배경이나 다른 요소보다 얼마나 위에 있는지 표현하는 시각적 깊이입니다. Compass는 그림자, 테두리, 표면색과 오버레이를 함께 사용합니다.": "Elevation conveys how far a surface sits above its background or other elements. Compass combines shadows, borders, surface colors, and overlays.",
"높이의 정의": "Defining elevation",
"높이는 실제 거리를 재는 px 값이 아니라, 요소 사이의 관계를 보여주는 표현입니다. 기본 콘텐츠는 평평하게 두고 메뉴나 시트처럼 떠 있는 표면을 구분합니다.": "Elevation describes relationships, not a physical height in pixels. Keep normal content flat and distinguish floating surfaces such as menus and sheets.",
"그림자는 표면이 떠 있는 인상을 만듭니다. z-index는 요소가 실제로 겹쳐지는 순서를 정합니다. 그림자가 커져도 z-index가 바뀌지 않으며, z-index가 높아도 그림자가 자동으로 생기지는 않습니다.": "Shadows create visual depth; z-index determines stacking order. Increasing a shadow does not change z-index, and a higher z-index does not create a shadow.",
"평면, 낮음, 중간, 높음은 그림자 강도를 설명하는 이름입니다. 컴포넌트는 shadow-sm, shadow-md, shadow-lg를 사용하며, 실제 값은 테마별 surface-shadow 토큰에서 정의합니다.": "Flat, Low, Medium, and High describe shadow strength. Components use shadow-sm, shadow-md, and shadow-lg, with values defined by theme-specific surface-shadow tokens.",
"그림자 스케일": "Shadow scale",
"같은 크기와 표면색에서 그림자만 비교합니다. 기본 표면은 그림자를 사용하지 않고, 떠 있는 표면에 필요한 만큼만 적용합니다.": "Compare shadows at the same size and surface color. Keep basic surfaces flat and apply only the depth floating surfaces need.",
"기본 콘텐츠 표면": "Basic content surface",
"선택된 탭의 표면": "Selected tab surface",
"메뉴와 선택 목록": "Menus and selection lists",
"컴포넌트 적용": "Component usage",
"현재 설치된 shadcn 컴포넌트의 실제 스타일을 따릅니다. 모든 카드나 버튼에 그림자를 추가하지 않습니다.": "Follow the installed shadcn components' actual styles. Do not add shadows to every card or button.",
"그림자 없음": "No shadow",
"테두리와 표면색으로 구분": "Distinguish with borders and surface color",
"선택된 탭": "Selected tab",
"배경 위에서 선택된 표면을 구분": "Distinguish the selected surface from its background",
"테마·언어 메뉴 / Dropdown": "Theme and language menus / Dropdown",
"기존 콘텐츠 위에 선택 목록 표시": "Show options above existing content",
"콘텐츠 위에 보조 작업 영역 표시": "Show a supporting task above content",
"그림자 대신 테두리와 배경 오버레이로 구분": "Distinguish with a border and backdrop instead of a shadow",
"겹침 순서": "Stacking order",
"레이어 순서는 현재 화면의 z-index를 기준으로 정의합니다. 같은 z-index의 오버레이와 팝업은 포털과 렌더링 순서로 함께 관리합니다.": "Layer order follows the current z-index values. Portals and rendering order coordinate overlays and popups that share the same z-index.",
"기본 콘텐츠": "Base content",
"페이지의 기본 흐름": "Normal document flow",
"Docs 사이드바": "Docs sidebars",
"화면 양쪽에 고정된 탐색 영역": "Fixed navigation at the screen edges",
"갤러리 필터": "Gallery filters",
"갤러리 정렬": "Gallery sort",
"기본": "Default",
"최신": "Latest",
"스크롤 중 유지되는 필터": "Filters retained while scrolling",
"헤더": "Header",
"필터와 사이드바 위의 공통 탐색": "Global navigation above filters and sidebars",
"메뉴·다이얼로그·시트": "Menus, dialogs, and sheets",
"페이지 위에 표시되는 팝업 영역": "Popup surfaces above the page",
"z-index는 같은 스태킹 컨텍스트 안에서 비교됩니다. 부모의 transform, opacity, isolation 등이 만드는 컨텍스트에 따라 큰 숫자를 지정해도 다른 영역 위로 올라오지 않을 수 있습니다.": "Z-index values are compared within a stacking context. Parent contexts created by transform, opacity, or isolation can prevent a higher value from appearing above another area.",
"테마와 상태": "Themes and states",
"라이트·다크 모드에서 같은 shadow 클래스를 사용하되, 다크 모드에서는 그림자의 농도와 퍼지는 범위를 단계별로 강화합니다. 테두리와 표면색도 함께 유지해 어두운 배경에서 깊이를 구분합니다.": "Use the same shadow classes in both themes, with stronger opacity and progressively wider shadows in dark mode. Retain borders and surface colors to distinguish depth on dark backgrounds.",
"키보드 포커스의 ring은 조작 위치를 알리는 표시이며 높이와 다릅니다. 버튼의 선택·호버 상태를 표현하기 위해 그림자 강도를 임의로 높이지 않습니다.": "Keyboard focus rings identify the current control and are separate from elevation. Do not arbitrarily increase shadow strength for button selection or hover states.",
"간격": "Spacing",
"간격 단위와 화면 적용 값": "Spacing units and layout values",
"Compass는 4px 기본 단위로 여백과 요소 사이 간격을 정의합니다. Tailwind의 공통 간격 스케일을 사용합니다.": "Compass defines padding and element spacing with a 4px base unit, using Tailwind's shared spacing scale.",
"기본 단위": "Base unit",
"기본 토큰은 Tailwind의 --spacing: 0.25rem입니다. 기본 글자 크기 16px 기준으로 1단위는 4px이며, 클래스 숫자에 기본 단위를 곱해 계산합니다.": "The base token is Tailwind's --spacing: 0.25rem. At a 16px root font size, one unit equals 4px. Multiply the class number by this base unit.",
"주로 4, 8, 12, 16, 24, 32, 40, 48px을 사용합니다. 작은 컴포넌트에는 1.5, 2.5, 3.5단위처럼 반 단위도 사용해 6, 10, 14px을 표현합니다. 글자 크기 설정이 바뀌면 rem 기반 간격도 함께 조정됩니다.": "Common values are 4, 8, 12, 16, 24, 32, 40, and 48px. Small controls also use half-unit steps such as 1.5, 2.5, and 3.5 for 6, 10, and 14px. Rem-based spacing follows the root font size.",
"간격 스케일": "Spacing scale",
"같은 단위를 padding, margin, gap에 공통으로 적용합니다.": "Use the same units for padding, margin, and gap.",
"단위": "Unit",
"값": "Value",
"간격의 역할": "Spacing roles",
"요소 간격은 gap, 내부 여백은 padding, 외부 간격은 margin으로 구분합니다. 같은 간격을 여러 곳에 중복 지정하지 않습니다.": "Use gap between elements, padding inside elements, and margin outside them. Avoid specifying the same space in multiple places.",
"나란히 놓인 요소 사이": "Between adjacent elements",
"요소 안쪽 여백": "Space inside an element",
"요소 바깥쪽 여백": "Space outside an element",
"좌우 16px": "16px on each side",
"위쪽 8px": "8px above",
"현재 화면의 적용 값": "Current layout values",
"헤더와 콘텐츠는 같은 좌우 여백을 사용합니다. 반응형 여백은 화면이 넓어질 때 16px, 32px, 40px 순으로 바뀝니다.": "The header and content share horizontal padding, increasing from 16px to 32px and 40px on wider screens.",
"헤더·콘텐츠 좌우": "Header and content gutters",
"갤러리 필터와 목록": "Gallery filters to grid",
"필터 pb-2 + 목록 mt-2": "Filter pb-2 + grid mt-2",
"갤러리 카드 사이": "Between gallery cards",
"기본 버튼 좌우": "Default button horizontal padding",
"기본 버튼 아이콘과 글자": "Default button icon to text",
"미리보기 안쪽": "Preview padding",
"문서 섹션 사이": "Between document sections",
"태블릿 기준은 768px, 데스크톱 기준은 1024px입니다. Docs 본문 최대 너비 640px은 간격 토큰이 아닌 레이아웃 너비 제한입니다.": "The tablet breakpoint is 768px and desktop is 1024px. The Docs body width limit of 640px is a layout constraint, not a spacing token.",
"문자": "Typography",
"버튼": "Button",
"텍스트 필드": "Text field",
"셀렉트": "Dropdown",
"드롭다운": "Dropdown",
"체크박스": "Multiple Selection",
"라디오 버튼": "Single Selection",
"다중 선택": "Multiple Selection",
"단일 선택": "Single Selection",
"슬라이더": "Slider",
"카드": "Card",
"바텀 시트": "Bottom sheet",
"칩": "Chips",
"칩 필터": "Chip Filter",
"아바타 모음": "Avatar Group",
"원형 게이지": "Circular Gauge",
"습관 추적": "Routine Tracker",
"활동 히트맵": "Activity Heatmap",
"브레드크럼": "Breadcrumb",
"맨 위로": "Back to Top",
"탭": "Tabs",
"다시 쓰기": "Start over",
"서명 전달하기": "Send signature",
"전하기": "Send",
"세그멘티드 컨트롤": "Segmented control",
"페이지네이션": "Pagination",
"점 페이지네이션": "Dot pagination",
"이미지": "Image",
"이미지 캐러셀": "Image carousel",
"동영상 재생바": "Video Playback Bar",
"필터": "Filter",
"뱃지": "Badge",
"인증됨": "Verified",
"알림 7개": "7 notifications",
"태그": "Tag",
"아바타": "Avatar",
"과정 표시": "Progress",
"로딩": "Loading",
"스켈레톤": "Skeleton",
"위키": "Wiki",
"리스트": "List",
"이미지 카드": "Image Card",
"네비게이션 바": "Navigation Bar",
"툴 바": "Toolbar",
"푸터": "Footer",
"팝오버": "Popover",
"모달": "Modal",
"사이드 패널": "Side Panel",
"토스트": "Toast",
"프로그레스 바": "Progress Bar",
"단계 진행 가로형": "Horizontal Step Progress",
"다이얼로그": "Dialog",
"플로팅 카드": "Floating card",
"팀": "Team",
"디자인": "Design",
"개발": "Development",
"리서치": "Research",
"볼륨": "Volume",
"밝기": "Brightness",
"바텀 시트 열기": "Open bottom sheet",
"보기 방식": "View mode",
"목록 또는 그리드를 선택하세요.": "Choose a list or grid view.",
"목록": "List",
"그리드": "Grid",
"항목": "Item",
"이전": "Previous",
"다음": "Next",
"페이지": "Page",
"전체": "All",
"결과": "Results",
"진행 중": "In progress",
"대기": "Pending",
"오류": "Error",
"제거": "Remove",
"초기화": "Reset",
"디자인 팀": "Design team",
"다음 단계": "Next step",
"기본 원칙": "Principles",
"예시": "Example",
"변경 사항을 저장합니다.": "Save your changes.",
"변경 사항 저장": "Save changes",
"설정이 저장되었습니다.": "Your settings have been saved.",
"Compass Design System의 기본 요소를 조합한 예시입니다.": "An example built with Compass Design System foundations.",
"미리보기": "Preview",
"사용 가이드": "Usage guidelines",
"사용할 때": "When to use",
"사용하지 않을 때": "When not to use",
"직접 클릭하고 상태를 바꿔보며 컴포넌트의 동작을 확인하세요.": "Interact with the component and explore its states.",
"기본 컴포넌트와 미리보기": "Basic components and previews",
"기본 컴포넌트를 직접 사용해보고, 각 페이지에서 미리보기를 확인합니다.": "Explore basic components and their interactive previews on each page.",
"사용자가 명령을 실행하거나 다음 단계로 이동할 때 쓰는 기본 액션 컴포넌트입니다.": "A basic action component for running commands or moving to the next step.",
"관련 정보를 하나의 표면에 묶어 보여줄 때 사용하는 컨테이너입니다.": "A container for grouping related information on one surface.",
"같은 맥락의 여러 패널을 한 영역 안에서 전환할 때 사용합니다.": "Switch between related panels in the same area.",
"페이지 흐름을 유지하면서 짧은 작업이나 상세 설정을 처리할 때 사용합니다.": "Handle focused tasks and settings without leaving the current page.",
"주요 액션에는 기본 버튼, 보조 액션에는 Outline이나 Secondary를 사용합니다. 실행할 수 없는 액션은 비활성 상태로 표시합니다.": "Use the default button for primary actions and Outline or Secondary for supporting actions. Disable unavailable actions.",
"중요한 변경을 적용하기 전에 확인을 받는 용도로 사용합니다. 확인과 취소를 명확히 구분하고, 일반 편집에는 Dialog를 사용합니다.": "Request confirmation before important changes. Clearly distinguish confirm and cancel; use Dialog for ordinary editing.",
"하나의 주제에 속한 제목, 설명, 정보, 액션을 묶습니다. 서로 다른 주제는 별도 카드로 나누고, 카드 안에 카드를 중첩하지 않습니다.": "Group a topic's title, description, information, and actions. Separate different topics and avoid nesting cards.",
"같은 맥락의 콘텐츠를 전환할 때 사용합니다. 방향키로 탭을 이동할 수 있으며, 독립된 문서 페이지로 이동할 때는 링크를 사용합니다.": "Switch related content using tabs and arrow keys. Use links for navigation to separate document pages.",
"현재 페이지에서 짧은 편집 작업을 처리할 때 사용합니다. 저장하면 결과를 반영하고, 취소하거나 Escape를 누르면 변경을 반영하지 않고 닫습니다.": "Use for short editing tasks on the current page. Save applies changes; Cancel or Escape closes without applying them.",
"비활성": "Disabled",
"버튼을 선택해 보세요.": "Select a button.",
"일관된 화면을 만드는 기본 요소와 컴포넌트": "Foundations and components for consistent interfaces",
"저장됨": "Saved",
"저장": "Save",
"개요": "Overview",
"걷기": "Walking",
"자동차": "Car",
"버스": "Bus",
"지하철": "Subway",
"한글과 영문을 함께 지원하는 기본 글꼴입니다.": "The default font supports Korean and English.",
"이름 수정": "Edit name",
"변경할 이름을 입력하고 저장하세요.": "Enter a new name and save your changes.",
"이름": "Name",
"프로필에 표시할 이름을 입력하세요.": "Enter the name to display on your profile.",
"이메일": "Email",
"이메일 주소를 입력하세요.": "Enter your email address.",
"이메일 형식이 올바르지 않습니다.": "Invalid email format.",
"올바른 이메일 형식입니다.": "Valid email format.",
"입력 지우기": "Clear input",
"현재 이름": "Current name",
"취소": "Cancel",
"파일을 여기에 올려주세요": "Drop your file here.",
"확인": "Confirm",
"확인 창 열기": "Open confirmation",
"변경 사항을 적용할까요?": "Apply these changes?",
"이 예시는 확인 흐름을 보여줍니다. 실제 데이터는 변경되지 않습니다.": "This example demonstrates a confirmation flow. No real data is changed.",
"확인했습니다.": "Confirmed.",
"확인 또는 취소를 선택해 보세요.": "Choose confirm or cancel.",
"디자인 시스템 써. 설정 페이지 만들어줘.": "Use the design system. Build a settings page.",
"토큰": "Token",
"용도": "Purpose",
"페이지 배경과 기본 텍스트": "Page background and default text",
"카드의 배경과 텍스트": "Card background and text",
"다이얼로그와 팝오버의 배경과 텍스트": "Dialog and popover background and text",
"주요 액션과 그 위의 텍스트": "Primary actions and their text",
"보조 액션과 그 위의 텍스트": "Secondary actions and their text",
"보조 영역과 설명 텍스트": "Supporting surfaces and descriptions",
"선택 또는 호버 상태": "Selected and hover states",
"삭제 등 주의가 필요한 액션": "Destructive actions such as deletion",
"구분선, 입력 테두리, 포커스 표시": "Dividers, input borders, and focus indicators",
"검색": "Search",
"코드": "Code",
"복사": "Copy",
"완료": "Done",
"닫기": "Close",
"Lucide 공식 문서": "Lucide documentation",
"AI로 설치하기": "Install with AI",
"디자인 시스템 설정": "Set up the design system",
"설치 후 사용하기": "After installation",
"레지스트리 상세": "Registry details",
"설정 프롬프트를 AI에게 전달해 프로젝트에 COMPASS를 설치하세요.": "Share the setup prompt with your AI to install COMPASS in your project.",
"적용할 프로젝트를 AI에서 열고, 아래 프롬프트를 복사해 전달하세요. AI가 프로젝트를 확인하고 설치와 규칙 등록을 진행합니다.": "Open your project in your AI tool and share the prompt below. Your AI will inspect the project, install COMPASS, and save its project rules.",
"프로젝트 파일 수정과 설치가 가능한 AI가 필요합니다. 배포 전 localhost 주소는 이 컴퓨터에서만 접근할 수 있습니다.": "Your AI needs access to edit project files and install dependencies. Before deployment, the localhost URL is only accessible on this computer.",
"처음 한 번 설정한 프로젝트에서는 이후 아래처럼 요청하세요. AI는 등록된 규칙과 설치한 컴포넌트를 사용합니다.": "After the initial setup, make requests like the example below. Your AI will use the saved rules and installed components.",
"직접 설치하거나 배포 항목과 버전을 확인할 때 참고하세요.": "Refer to these details for manual installation, available items, and versions.",
"타이포그래피": "Typography",
"색": "Colors",
"아이콘": "Icons",
"테마 및 언어": "Theme and language",
"테마": "Theme",
"라이트·다크 모드 전환": "Toggle light and dark mode",
"모드 변경": "Change mode",
"라이트": "Light",
"다크": "Dark",
"시스템 설정": "System",
"언어": "Language",
"문서 구성": "Documentation",
"프로젝트 구성": "Project structure",
"컴포넌트 추가": "Add a component",
"여러 컴포넌트 추가": "Add multiple components",
"소스 관리": "Source management",
"라이트 / 다크": "Light / Dark",
"색상 토큰": "Color tokens",
"이 프로젝트에서 사용": "Use in this project",
"다른 프로젝트에 연결": "Connect another project",
"AI용 문서": "AI reference",
"폰트": "Font",
"타입 스케일": "Type scale",
"굵기": "Weights",
"사용 예시": "Usage",
"색상 팔레트": "Color palette",
"토큰의 역할": "Token roles",
"아이콘 라이브러리": "Icon library",
"설치": "Installation",
"크기와 선 두께": "Sizes and stroke width",
"접근성": "Accessibility",
"복사됨": "Copied",
"코드 복사": "Copy code",
"새로고침": "Refresh",
"프롬프트 복사": "Copy prompt",
"디자인 시스템 설정 프롬프트 복사": "Copy design system setup prompt",
"디자인 시스템 설정 프롬프트": "Design system setup prompt",
"설정 프롬프트를 복사했습니다. 프로젝트를 열어 둔 AI에게 전달해 주세요.": "Setup prompt copied. Share it with the AI working in your project.",
"복사하지 못했습니다. 아래 프롬프트를 직접 선택해 복사해 주세요.": "Copy failed. Select and copy the prompt below manually.",
"문서 인덱스": "Reference index",
"전체 참조 보기": "View full reference",
"전체 참조 다운로드": "Download full reference",
"AI에 전달할 연결 지침을 복사했습니다.": "Setup instructions copied. Share them with your AI.",
"복사하지 못했습니다. 아래 AI용 문서를 다운로드해 전달해 주세요.": "Copy failed. Download the AI reference below and share it instead.",
"AI에 Compass를 연결하면 짧은 요청으로 동일한 디자인 시스템을 적용할 수 있습니다.": "Connect Compass to your AI and apply the design system with a short request.",
"이 프로젝트의 AGENTS.md에 Compass 지침을 등록했습니다. 프로젝트 지침을 읽는 AI에게 아래처럼 요청하면 됩니다.": "Compass rules are registered in this project's AGENTS.md. Ask an AI that reads project instructions using the example below.",
"처음 한 번 연결 지침을 AI에게 전달해 프로젝트 규칙에 Compass를 등록합니다. 연결 후에는 ‘디자인 시스템 써’라고 요청할 수 있습니다.": "Share the setup instructions once to register Compass in your project rules. Then just ask your AI to use the design system.",
"AI용 참조에는 디자인 규칙, 실제 테마 CSS, 컴포넌트 소스와 의존성이 포함되어 있습니다. 개발 서버와 빌드 시작 전에 현재 소스로 갱신됩니다.": "The AI reference includes design rules, theme CSS, component sources, and dependencies. It refreshes from current sources before development and builds.",
"AI는 기존 shadcn 컴포넌트, Pretendard, Lucide와 Compass 색상 토큰을 기준으로 작업합니다.": "Your AI uses the existing shadcn components, Pretendard, Lucide, and Compass color tokens.",
"localhost 주소는 이 컴퓨터에서만 접근할 수 있습니다. 외부 AI에는 아래 참조 문서를 다운로드해 첨부하거나, 공개된 사이트 주소를 전달해 주세요.": "The localhost URL is only accessible on this computer. For a remote AI, attach the downloaded reference or share a publicly hosted URL.",
"Compass Design System은 shadcn 기반의 디자인 시스템입니다. Pretendard, Lucide, 공통 디자인 토큰과 컴포넌트로 일관된 화면을 만듭니다.": "Compass Design System is built on shadcn. Create consistent interfaces with Pretendard, Lucide, shared design tokens, and components.",
"설치와 테마 설정은 시작하기에서, 글꼴·색·아이콘의 기준은 기본 요소에서 확인합니다.": "Get Started covers installation and theming. Elements defines typography, colors, and icons.",
"프로젝트 구성과 shadcn 컴포넌트를 추가하는 방법을 소개합니다.": "Learn how the project is structured and how to add shadcn components.",
"프로젝트는 Next.js와 shadcn CLI로 시작했습니다. 스타일은 Tailwind CSS, 글꼴은 Pretendard, 아이콘은 Lucide를 사용합니다.": "The project uses Next.js and the shadcn CLI, with Tailwind CSS, Pretendard, and Lucide.",
"컴포넌트는 필요한 시점에 하나씩 추가합니다. 프로젝트 폴더에서 명령을 실행하면 components/ui에 소스가 생성됩니다.": "Add components as needed. Run the command in your project directory to create sources in components/ui.",
"라이트·다크 모드와 디자인 토큰으로 일관된 테마를 적용합니다.": "Apply consistent themes using light and dark modes and design tokens.",
"상단 오른쪽의 테마 버튼으로 모드를 전환합니다. 기본 토큰은 :root, 다크 모드 토큰은 .dark에 정의되어 있습니다.": "Click the top-right theme button to switch modes. Default tokens are defined in :root and dark tokens in .dark.",
"색상은 app/globals.css에서 관리합니다. 현재 Primary와 Accent는 두 모드 모두 #007AFF를 사용합니다.": "Colors are managed in app/globals.css. Primary and Accent use #007AFF in both modes.",
"기본 글꼴과 제목은 Pretendard를 사용합니다. 코드에는 font-mono를 적용해 고정폭 글꼴로 구분합니다.": "Body text and headings use Pretendard. Code uses a monospace font through font-mono.",
"색상 문서 보기": "View color documentation",
"타이포그래피 문서 보기": "View typography documentation",
"shadcn CLI로 필요한 컴포넌트 소스를 프로젝트에 추가합니다.": "Use the shadcn CLI to add component sources to your project.",
"add 명령 뒤에 컴포넌트 이름을 지정합니다. 프로젝트의 components.json 설정에 따라 컴포넌트와 의존성이 추가됩니다.": "Specify a component name after add. Components and dependencies follow your components.json configuration.",
"여러 이름을 함께 전달하면 필요한 컴포넌트를 한 번에 추가할 수 있습니다.": "Pass multiple names to add several components at once.",
"추가된 소스는 components/ui에서 직접 관리합니다. 설치된 컴포넌트의 상세 문서에서 미리보기와 설치 명령을 확인할 수 있습니다.": "Manage added sources in components/ui. Component documentation includes previews and installation commands.",
"Pretendard를 기반으로 제목, 본문, 설명에 일관된 텍스트 스타일을 적용합니다.": "Use Pretendard for consistent headings, body text, and descriptions.",
"기본 글꼴은 Pretendard Variable입니다. 한글과 영문을 함께 사용하며, 코드에는 별도의 고정폭 글꼴을 적용합니다.": "The default font is Pretendard Variable for Korean and English. Code uses a separate monospace font.",
"페이지 제목은 Display, 섹션 제목은 Heading, 본문은 Body를 사용합니다. 보조 정보는 Muted, 코드와 토큰은 Code 스타일로 구분합니다.": "Use Display for page titles, Heading for sections, and Body for paragraphs. Use Muted for supporting text and Code for tokens and snippets.",
"본문은 400, 버튼과 라벨은 500, 제목은 600을 기본으로 사용합니다. 700은 강조가 필요한 짧은 텍스트에 사용합니다.": "Use 400 for body text, 500 for buttons and labels, and 600 for headings. Reserve 700 for short emphasis.",
"font-sans는 Pretendard, font-mono는 코드용 글꼴을 사용합니다. 본문은 충분한 줄 높이를 두어 읽기 편하게 유지합니다.": "font-sans uses Pretendard; font-mono uses the code font. Give body text enough line height for readability.",
"shadcn의 의미 기반 색상 토큰으로 화면과 상태를 표현합니다. 모든 토큰은 라이트 모드와 다크 모드에 대응합니다.": "Express surfaces and states with semantic shadcn color tokens. All tokens support light and dark modes.",
"현재 테마에 적용된 실제 색상입니다. 상단의 테마 버튼으로 모드를 전환하면 팔레트도 함께 바뀝니다.": "These are the current theme's colors. Clicking the theme button also updates the palette.",
"색상 값 대신 역할에 맞는 토큰을 선택합니다. 배경과 그 위의 텍스트는 foreground 토큰과 함께 사용합니다.": "Choose tokens by role instead of raw colors. Pair backgrounds with their foreground tokens.",
"기본 색상은 :root, 다크 모드 색상은 .dark에 정의되어 있습니다. 동일한 토큰을 사용하면 모드에 따라 색상이 자동으로 전환됩니다.": "Default colors are defined in :root and dark colors in .dark. The same token automatically adapts to the current mode.",
"Tailwind 클래스에서 테마 토큰을 바로 사용할 수 있습니다. 테두리와 포커스 표시에도 같은 토큰 체계를 적용합니다.": "Use theme tokens directly in Tailwind classes, including borders and focus indicators.",
"Lucide 아이콘을 사용합니다. 검색, 테마 전환, 복사 등 반복되는 액션을 일관된 형태로 표현합니다.": "Use Lucide for consistent search, theme, copy, and other recurring actions.",
"현재 디자인 시스템에 사용하는 기본 아이콘입니다. 아이콘 색상은 주변 텍스트의 currentColor를 따릅니다.": "These are the system's core icons. Their color follows the surrounding text through currentColor.",
"lucide-react 패키지를 설치합니다. 이 프로젝트에는 이미 적용되어 있습니다.": "Install lucide-react. It is already installed in this project.",
"버튼과 입력에는 16px, 메뉴에는 20px, 독립된 표시에는 24px를 사용합니다. 기본 선 두께는 2로 통일합니다.": "Use 16px in controls, 20px in navigation, and 24px for standalone indicators, with a 2 stroke width.",
"필요한 아이콘만 가져와 React 컴포넌트로 사용합니다. 버튼의 크기와 정렬은 기존 Button 컴포넌트를 사용합니다.": "Import only the icons you need and render them as React components. Use the existing Button component for sizing and alignment.",
"텍스트와 함께 쓰는 아이콘은 aria-hidden으로 중복 읽기를 막습니다. 아이콘만 있는 버튼에는 동작을 설명하는 aria-label과 title을 함께 지정합니다.": "Hide decorative icons with aria-hidden. Give icon-only buttons an aria-label and title describing their action.",
"AI에 Compass Design System 연결하기": "Connect Compass Design System to your AI",
"프로젝트 구성과 컴포넌트 설치": "Project structure and component installation",
"라이트·다크 모드와 디자인 토큰": "Light and dark modes and design tokens",
"shadcn 명령으로 컴포넌트 추가": "Add components with the shadcn CLI",
"Pretendard와 타입 스케일": "Pretendard and the type scale",
"색상 팔레트와 토큰의 역할": "Color palette and token roles",
"Lucide와 사용 기준": "Lucide and usage guidelines",
}
export function translate(text: string, locale: Locale) {
return locale === "en" ? (english[text] ?? text) : (korean[text] ?? text)
}
import { catalogTranslations } from "@/lib/docs-catalog"
```
## Source: lib/button-guideline-translations.ts
```ts
export const buttonGuidelineTranslations: Record = {
"텍스트": "Text",
"그래픽": "Graphics",
"모양": "Shape",
"부드러운 인상으로 텍스트에 집중하게 돕는 둥근 네모형이 많이 쓰입니다. 직사각형은 뾰족한 영역의 자극에 시선이 모이는 현상이 있고 둥근 네모는 중앙으로 시선이 부드럽게 모이는 현상이 있습니다.": "Rounded rectangles are commonly used to create a soft impression and help people focus on the text. Sharp corners in a rectangle tend to draw attention, while a rounded rectangle gently guides the eye toward the center.",
"경우에 따라 양 끝이 완전히 둥근 버튼을 쓰기도 합니다. 곡률이 같은데 크기가 작아지면 선분이 찌그러져 보이는 현상도 있어 크기가 작으면 곡률을 높이는 것이 자연스럽습니다.": "Some buttons have fully rounded ends. At smaller sizes, the same corner radius can make the outline appear compressed, so increasing the roundness can look more natural.",
"크기": "Size",
"버튼의 크기는 다른 요소와의 관계에 따라 다르게 정의합니다. 버튼 단독으로 크기를 정할 때는 주로 서비스에서 정한 최소 단위 간격을 중심으로 정합니다. 작음, 중간, 큼으로 단순하게 정하는 것이 쓰기 좋습니다.": "Button sizes depend on their relationship to other elements. When defining a button on its own, start with the service's smallest spacing unit. A simple set of small, medium, and large sizes is easy to use.",
"작은 버튼은 높이 32px로 대시보드와 밀도가 높은 화면에 사용합니다. 중간 버튼은 높이 40px로 일반적인 액션에 사용하고, 큰 버튼은 높이 56px로 모바일 하단의 주요 액션에 사용합니다.": "Small buttons are 32px high for dashboards and dense screens. Medium buttons are 40px high for everyday actions, and large buttons are 56px high for primary actions at the bottom of mobile screens.",
"곡률": "Corner radius",
"편의를 위해 버튼 및 레이어의 꼭지점의 곡률을 동일하게 적용하는 경우가 많습니다. 절대 수치로 곡률 값을 지정하면 크기가 작아져도 원 크기가 고정됩니다. 버튼의 크기가 작으면 꼭지점이 더 둥글어 보입니다. 자연스러운 표현을 위해서는 버튼의 크기 변화를 규격화하고 각 크기에 알맞은 곡률을 적용하는 것이 좋습니다.": "Buttons and layers are often given the same corner radius for convenience. An absolute radius stays fixed as the element gets smaller, making smaller buttons appear rounder. For a natural appearance, standardize button sizes and choose a suitable radius for each size.",
"연속형 모서리": "Continuous corners",
"모든 둥근 모서리가 같은 방식으로 그려지는 것은 아닙니다. 원형 모서리는 단순한 호를 따르기 때문에 직선 가장자리에서 곡선으로 넘어가는 변화가 더 눈에 띄게 느껴질 수 있습니다. 반면 연속형 모서리는 곡선이 가장자리와 더 부드럽게 이어지도록 만들어, 같은 반경이라도 더 부드럽고 덜 갑작스럽게 느껴지게 합니다.": "Not all rounded corners are drawn the same way. Circular corners follow a simple arc, so the transition from a straight edge to a curve can feel more noticeable. Continuous corners join the curve to the edge more gradually, making the same radius feel softer and less abrupt.",
"두 예시는 같은 크기의 완전히 둥근 버튼입니다. 원형은 일정한 곡률의 원호를 사용하고, 연속형은 직선으로 이어지는 구간의 곡률을 점진적으로 변화시킵니다. 아래 확대 비교도 버튼과 동일한 곡선을 사용합니다. 이 웹 표현은 iOS 네이티브 곡선과 완전히 동일한 구현은 아닙니다.": "Both examples are fully rounded buttons of the same size. Circular corners use an arc with constant curvature, while continuous corners gradually change curvature as they meet the straight edge. The enlarged comparison below uses the same curves as the buttons. This web representation is not an exact implementation of the native iOS curve.",
"여백": "Padding",
"버튼을 설명하는 텍스트를 중심으로 가로 세로 여백을 정합니다. 사람의 눈은 수평 탐색에 더 익숙하기 때문에 좌우로 넓은 것이 더 익숙합니다. 세로 여백보다 가로 여백이 긴 것이 자연스럽습니다.": "Define horizontal and vertical padding around the button's text. Our eyes are more accustomed to scanning horizontally, so wider horizontal padding feels familiar. Horizontal padding that is greater than vertical padding looks natural.",
"색": "Color",
"버튼의 색은 자유롭게 선택합니다. 주로 브랜드나 서비스의 정체성을 표현하는 색을 선택합니다. 사용성을 위해 텍스트는 식별이 쉽도록 흰색 혹은 같은 색조의 어두운 색을 사용합니다. 검정색을 사용할 수도 있습니다. 배경색은 내부에 담기는 텍스트가 잘 보일 수 있게 최소한의 명도 대비를 유지합니다.": "Button colors can be chosen freely, usually to express a brand or service's identity. For readability, use white text or a darker shade of the same hue. Black text is also an option. Maintain enough contrast between the background and the text to keep the label legible.",
"색 위계": "Color hierarchy",
"가장 중요도가 높은 요소에 주색을 사용합니다. 순서대로 보조색, 무채색 등을 이용해 버튼의 중요도와 상태를 표현합니다. 그라데이션을 이용해 누르는 요소라는 것을 더 강조하기도 합니다. 스마트폰에서는 단색으로도 충분히 버튼임이 이해되어 많이 사용하지는 않습니다.": "Use the primary color for the most important element. Secondary colors and neutral tones then communicate importance and state. Gradients can emphasize that an element is pressable, but solid colors are usually enough to identify buttons on smartphones, so gradients are less common.",
"글 쓰기": "Writing labels",
"버튼을 누르면 무슨 일이 생길지 알려 줍니다. 버튼을 누르면 페이지가 이동하는지, 정보를 제출하는지, 지금 하던 행동을 취소하는지 등 가능한 구체적으로 알려줍니다.": "Tell people what will happen when they press a button. Be as specific as possible about whether it navigates to another page, submits information, or cancels the current action.",
"버튼은 주로 동사를 표현하게 되는데 주어와 목적어를 명확하게 해석할 수 있는 독립적이고 구체적인 단어를 쓰는 것이 좋습니다. ‘예’라고만 표현할 경우 무엇에 대한 긍정인지, 긍정하면 무슨 일이 생기는지 알 수 없습니다.": "Buttons usually express actions. Use specific, self-contained wording that makes the subject and object clear. A label such as 'Yes' does not explain what is being agreed to or what will happen next.",
"만약 사람이라면 사용자에게 무어라 말할지를 가정해 평소에 쓰는 언어로 말을 거는 것이 자연스럽습니다.": "Imagine what you would say to someone in person, and use familiar, everyday language.",
"스타일": "Style",
"용도에 따라 다양한 시각 기법을 조합해 스타일을 만듭니다. 중요한 정보를 자극 정도에 따라 사용합니다. 가장 많이 쓰이는 스타일은 주색으로 배경을 칠한 filled 스타일입니다. 배경의 색과 테두리를 변형해 여러 변주를 만듭니다. 배경이 없는 텍스트 버튼도 있습니다.": "Combine visual techniques to create styles for different purposes. Match visual emphasis to the importance of the information. The most common style is a filled button with a primary-color background. Variations change the background color and border, and text buttons have no background.",
"버튼 스타일에 가장 중요한 것은 ‘누를 수 있다’를 알려주는 장치를 꼭 포함하는 것입니다. 예를 들어 텍스트 버튼에 밑줄이나 아이콘이 없다면 누를 수 있는 요소인지 단순히 색이 들어간 글자인지 구분할 수 없게 됩니다.": "The most important part of a button's style is a clear cue that it can be pressed. Without an underline or an icon, for example, a text button can be hard to distinguish from ordinary colored text.",
"컴포지션": "Composition",
"레이어": "Layers",
"버튼은 전체를 담는 바탕 레이어와 내부에 포함되는 레이어로 구분됩니다. 텍스트 레이어가 사용자에게 전달되어야 하는 핵심 콘텐츠입니다. 아이콘은 텍스트의 의미 전달을 강화합니다. 공간을 절약하면서 의미를 직관적으로 전달하기 위해 텍스트 대신 단독으로 쓰기도 합니다.": "A button consists of a background layer and the layers inside it. The text is the core content that must reach the user. Icons reinforce the meaning of the text and can also be used on their own to save space while conveying meaning intuitively.",
"바탕 레이어는 누를 수 있는 영역을 알립니다. 내부를 채우는 Fill과 테두리 선 Stroke를 정의합니다. 다양한 스타일로 표면과 테두리 성격을 설정합니다.": "The background layer indicates the clickable area. Its fill and border stroke define the surface and outline, which can take different forms depending on the style.",
"주로 단색으로 표현하는 편이며 데스크탑 환경에서는 버튼을 튀어나오게 그라데이션으로 표현하기도 합니다. 다른 요소와 구분하기 위해 상황에 따라 그림자를 이용해 버튼이 공중에 뜬 것처럼 표현하기도 합니다.": "Buttons usually use solid colors. Desktop interfaces sometimes use gradients to make buttons appear raised. Shadows can also make a button appear to float above other elements when separation is needed.",
"조직화": "Organization",
"텍스트 단독으로 쓰이는 경우가 많지만 식별을 돕기 위해 아이콘과 결합해서도 많이 씁니다.": "Text often appears on its own, but icons are also used alongside it to make the action easier to identify.",
"앞에 아이콘이 붙을 때는 텍스트의 의미를 동일하게 표현하는 경우가 많습니다. 텍스트와 비슷한 두께와 무게로 글자와 아이콘이 하나의 서체인 것처럼 표현하는 편입니다.": "A leading icon often expresses the same meaning as the text. Matching the icon's stroke and visual weight to the text helps them feel like parts of the same typeface.",
"텍스트 뒤에 아이콘이 쓰일 때는 주로 페이지를 이동하거나 레이아웃에 변화가 있을 때 많이 쓰이는 편입니다. 새로운 페이지가 열리거나 아코디언이 열릴 때 쓰입니다.": "A trailing icon is often used when an action navigates to another page or changes the layout, such as opening a new page or expanding an accordion.",
"위계": "Hierarchy",
"여러 버튼을 쓸 때는 위계를 명확히 합니다. 주색을 가득 채운 Primary를 1순위로 쓰고 같은 색조의 옅은 배경과 주색 텍스트를 가진 Secondary를 2순위로 사용합니다. 3순위인 Tertiary는 무채색 배경과 중립색 텍스트로 중요도를 낮춰 구분합니다.": "When using multiple buttons, make their hierarchy clear. Primary uses a solid primary-color background for the most important action. Secondary uses a light background of the same hue with primary-color text for the next level. Tertiary uses a neutral background and neutral text to indicate lower importance.",
"면으로 된 1순위 버튼과 테두리로 구성된 2순위 버튼을 사용하기도 합니다. 면과 선으로 확실하게 구분할 수 있는 장점이 있습니다. 다만 1순위 버튼과 2순위 버튼의 대비 정도가 심해 2순위 버튼의 힘이 과하게 약해지는 경향이 있습니다.": "A filled button can also represent the primary action, with an outlined button for the secondary action. Fill and outline clearly distinguish the two. However, strong contrast between them can make the secondary action feel too weak.",
"가이드": "Guide",
"버튼은 UI의 가장 기본적인 인터랙션입니다. 모든 UI는 버튼에서 출발한다고 해도 과언이 아닙니다. 사용자가 기계에게 의사를 전달하는 가장 단순하고 직접적인 요소이기 때문입니다.": "Buttons are the most fundamental interaction in a UI. It is no exaggeration to say that every UI starts with a button: it is the simplest and most direct way for people to communicate their intentions to a machine.",
"화면 속 버튼은 단순한 그래픽 요소가 아닙니다. 사용자가 무언가를 결정하고, 실행하고, 이동하고, 취소하고, 저장하는 순간마다 버튼은 행동의 접점이 됩니다. 좋은 버튼은 사용자가 “무엇을 할 수 있는지”를 분명하게 알려주고, 그 행동의 결과를 예측할 수 있게 돕습니다.": "A button on a screen is more than a graphic. Whenever someone decides, executes, navigates, cancels, or saves, the button is where that action takes place. A good button clearly communicates what people can do and helps them anticipate the result.",
"버튼 디자인에서 중요한 것은 예쁘게 보이는 것만이 아닙니다. 버튼은 눌러야 할 것처럼 보여야 하고, 어떤 행동을 수행하는지 명확해야 하며, 현재 상태와 우선순위를 시각적으로 전달해야 합니다. 같은 버튼이라도 색상, 크기, 위치, 문구, 아이콘, 여백에 따라 사용자가 느끼는 중요도와 행동 가능성은 크게 달라집니다.": "Button design is about more than appearance. A button should look pressable, make its action clear, and visually communicate its current state and priority. Even with the same button, color, size, position, wording, icons, and spacing can significantly change its perceived importance and how actionable it feels.",
"직사각형": "Rectangle",
"둥근 네모": "Rounded rectangle",
"알약": "Pill",
"작음 · 32px": "Small · 32px",
"중간 · 40px": "Medium · 40px",
"큼 · 56px": "Large · 56px",
"좁은 여백": "Tight padding",
"자연스러운 여백": "Balanced padding",
"과한 여백": "Excessive padding",
"바탕": "Background",
"앞": "Leading",
"뒤": "Trailing",
"아이콘만": "Icon only",
"원형 모서리": "Circular corners",
"추가": "Add",
"삭제": "Delete",
"원형과 연속형 모서리의 위쪽 접합부 확대 비교": "Enlarged comparison of the upper transition between circular and continuous corners",
"위의 완전히 둥근 두 버튼과 동일한 모서리를 같은 배율로 확대해 겹쳐 표시합니다. 원형은 중립색 점선, 연속형은 파란 실선입니다.": "The same corners as the two fully rounded buttons above are enlarged at the same scale and overlaid. Circular corners use a neutral dashed line; continuous corners use a solid blue line.",
}
```
## Source: components/component-previews.tsx
```tsx
"use client"
import { useState } from "react"
import { createPortal } from "react-dom"
import { useLocale } from "@/components/locale-provider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from "@/components/ui/dialog"
const previewClass = "flex min-h-64 flex-col items-center justify-center gap-6 rounded-container border bg-background p-6"
function GalleryButtonPreview() {
const { t } = useLocale()
return (
{locale === "ko" ? "필수 쿠키는 서비스 운영에 사용해요. 선택 쿠키를 허용하면 이용 경험을 개선하는 데 도움이 돼요." : "Essential cookies keep the service running. Optional cookies help us improve your experience."}
, document.body
)}
{decision && (locale === "ko" ? decision === "allowed" ? "모든 쿠키를 허용했어요." : "필수 쿠키만 허용했어요." : decision === "allowed" ? "All cookies allowed." : "Only essential cookies allowed.")}
}
)
}
export { ComponentPreview }
```
## Source: components/button-guidelines.tsx
```tsx
import type { ReactNode } from "react"
import { ButtonGuidelinePreview } from "@/components/button-guideline-preview"
import { LocaleText } from "@/components/locale-provider"
import { buttonGuidelineSectionId } from "@/lib/button-guideline-navigation"
const groups = [
{
id: "button-graphic",
title: "그래픽",
sections: [
{
title: "모양",
previews: ["shape"],
paragraphs: [
"부드러운 인상으로 텍스트에 집중하게 돕는 둥근 네모형이 많이 쓰입니다. 직사각형은 뾰족한 영역의 자극에 시선이 모이는 현상이 있고 둥근 네모는 중앙으로 시선이 부드럽게 모이는 현상이 있습니다.",
"경우에 따라 양 끝이 완전히 둥근 버튼을 쓰기도 합니다. 곡률이 같은데 크기가 작아지면 선분이 찌그러져 보이는 현상도 있어 크기가 작으면 곡률을 높이는 것이 자연스럽습니다.",
],
},
{
title: "크기",
previews: ["size"],
paragraphs: [
"버튼의 크기는 다른 요소와의 관계에 따라 다르게 정의합니다. 버튼 단독으로 크기를 정할 때는 주로 서비스에서 정한 최소 단위 간격을 중심으로 정합니다. 작음, 중간, 큼으로 단순하게 정하는 것이 쓰기 좋습니다.",
"작은 버튼은 높이 32px로 대시보드와 밀도가 높은 화면에 사용합니다. 중간 버튼은 높이 40px로 일반적인 액션에 사용하고, 큰 버튼은 높이 56px로 모바일 하단의 주요 액션에 사용합니다.",
],
},
{
title: "곡률",
previews: ["radius"],
paragraphs: ["편의를 위해 버튼 및 레이어의 꼭지점의 곡률을 동일하게 적용하는 경우가 많습니다. 절대 수치로 곡률 값을 지정하면 크기가 작아져도 원 크기가 고정됩니다. 버튼의 크기가 작으면 꼭지점이 더 둥글어 보입니다. 자연스러운 표현을 위해서는 버튼의 크기 변화를 규격화하고 각 크기에 알맞은 곡률을 적용하는 것이 좋습니다."],
},
{
title: "연속형 모서리",
previews: ["corners"],
paragraphs: [
"모든 둥근 모서리가 같은 방식으로 그려지는 것은 아닙니다. 원형 모서리는 단순한 호를 따르기 때문에 직선 가장자리에서 곡선으로 넘어가는 변화가 더 눈에 띄게 느껴질 수 있습니다. 반면 연속형 모서리는 곡선이 가장자리와 더 부드럽게 이어지도록 만들어, 같은 반경이라도 더 부드럽고 덜 갑작스럽게 느껴지게 합니다.",
"두 예시는 같은 크기의 완전히 둥근 버튼입니다. 원형은 일정한 곡률의 원호를 사용하고, 연속형은 직선으로 이어지는 구간의 곡률을 점진적으로 변화시킵니다. 아래 확대 비교도 버튼과 동일한 곡선을 사용합니다. 이 웹 표현은 iOS 네이티브 곡선과 완전히 동일한 구현은 아닙니다.",
],
},
{
title: "여백",
previews: ["padding"],
paragraphs: ["버튼을 설명하는 텍스트를 중심으로 가로 세로 여백을 정합니다. 사람의 눈은 수평 탐색에 더 익숙하기 때문에 좌우로 넓은 것이 더 익숙합니다. 세로 여백보다 가로 여백이 긴 것이 자연스럽습니다."],
},
{
title: "색",
previews: ["color"],
paragraphs: ["버튼의 색은 자유롭게 선택합니다. 주로 브랜드나 서비스의 정체성을 표현하는 색을 선택합니다. 사용성을 위해 텍스트는 식별이 쉽도록 흰색 혹은 같은 색조의 어두운 색을 사용합니다. 검정색을 사용할 수도 있습니다. 배경색은 내부에 담기는 텍스트가 잘 보일 수 있게 최소한의 명도 대비를 유지합니다."],
},
{
title: "색 위계",
previews: ["color-hierarchy"],
paragraphs: ["가장 중요도가 높은 요소에 주색을 사용합니다. 순서대로 보조색, 무채색 등을 이용해 버튼의 중요도와 상태를 표현합니다. 그라데이션을 이용해 누르는 요소라는 것을 더 강조하기도 합니다. 스마트폰에서는 단색으로도 충분히 버튼임이 이해되어 많이 사용하지는 않습니다."],
},
{
title: "글 쓰기",
previews: ["word"],
paragraphs: [
"버튼을 누르면 무슨 일이 생길지 알려 줍니다. 버튼을 누르면 페이지가 이동하는지, 정보를 제출하는지, 지금 하던 행동을 취소하는지 등 가능한 구체적으로 알려줍니다.",
"버튼은 주로 동사를 표현하게 되는데 주어와 목적어를 명확하게 해석할 수 있는 독립적이고 구체적인 단어를 쓰는 것이 좋습니다. ‘예’라고만 표현할 경우 무엇에 대한 긍정인지, 긍정하면 무슨 일이 생기는지 알 수 없습니다.",
"만약 사람이라면 사용자에게 무어라 말할지를 가정해 평소에 쓰는 언어로 말을 거는 것이 자연스럽습니다.",
],
},
{
title: "스타일",
previews: ["style"],
paragraphs: [
"용도에 따라 다양한 시각 기법을 조합해 스타일을 만듭니다. 중요한 정보를 자극 정도에 따라 사용합니다. 가장 많이 쓰이는 스타일은 주색으로 배경을 칠한 filled 스타일입니다. 배경의 색과 테두리를 변형해 여러 변주를 만듭니다. 배경이 없는 텍스트 버튼도 있습니다.",
"버튼 스타일에 가장 중요한 것은 ‘누를 수 있다’를 알려주는 장치를 꼭 포함하는 것입니다. 예를 들어 텍스트 버튼에 밑줄이나 아이콘이 없다면 누를 수 있는 요소인지 단순히 색이 들어간 글자인지 구분할 수 없게 됩니다.",
],
},
],
},
{
id: "button-composition",
title: "컴포지션",
sections: [
{
title: "레이어",
previews: ["layer"],
paragraphs: [
"버튼은 전체를 담는 바탕 레이어와 내부에 포함되는 레이어로 구분됩니다. 텍스트 레이어가 사용자에게 전달되어야 하는 핵심 콘텐츠입니다. 아이콘은 텍스트의 의미 전달을 강화합니다. 공간을 절약하면서 의미를 직관적으로 전달하기 위해 텍스트 대신 단독으로 쓰기도 합니다.",
"바탕 레이어는 누를 수 있는 영역을 알립니다. 내부를 채우는 Fill과 테두리 선 Stroke를 정의합니다. 다양한 스타일로 표면과 테두리 성격을 설정합니다.",
"주로 단색으로 표현하는 편이며 데스크탑 환경에서는 버튼을 튀어나오게 그라데이션으로 표현하기도 합니다. 다른 요소와 구분하기 위해 상황에 따라 그림자를 이용해 버튼이 공중에 뜬 것처럼 표현하기도 합니다.",
],
},
{
title: "조직화",
previews: ["icon"],
paragraphs: [
"텍스트 단독으로 쓰이는 경우가 많지만 식별을 돕기 위해 아이콘과 결합해서도 많이 씁니다.",
"앞에 아이콘이 붙을 때는 텍스트의 의미를 동일하게 표현하는 경우가 많습니다. 텍스트와 비슷한 두께와 무게로 글자와 아이콘이 하나의 서체인 것처럼 표현하는 편입니다.",
"텍스트 뒤에 아이콘이 쓰일 때는 주로 페이지를 이동하거나 레이아웃에 변화가 있을 때 많이 쓰이는 편입니다. 새로운 페이지가 열리거나 아코디언이 열릴 때 쓰입니다.",
],
},
{
title: "위계",
previews: ["hierarchy"],
paragraphs: [
"여러 버튼을 쓸 때는 위계를 명확히 합니다. 주색을 가득 채운 Primary를 1순위로 쓰고 같은 색조의 옅은 배경과 주색 텍스트를 가진 Secondary를 2순위로 사용합니다. 3순위인 Tertiary는 무채색 배경과 중립색 텍스트로 중요도를 낮춰 구분합니다.",
"면으로 된 1순위 버튼과 테두리로 구성된 2순위 버튼을 사용하기도 합니다. 면과 선으로 확실하게 구분할 수 있는 장점이 있습니다. 다만 1순위 버튼과 2순위 버튼의 대비 정도가 심해 2순위 버튼의 힘이 과하게 약해지는 경향이 있습니다.",
],
},
],
},
]
function ButtonGuidelines({ children }: { children?: ReactNode }) {
return (
<>
가이드
{children &&
{children}
}
위키
버튼은 UI의 가장 기본적인 인터랙션입니다. 모든 UI는 버튼에서 출발한다고 해도 과언이 아닙니다. 사용자가 기계에게 의사를 전달하는 가장 단순하고 직접적인 요소이기 때문입니다.
화면 속 버튼은 단순한 그래픽 요소가 아닙니다. 사용자가 무언가를 결정하고, 실행하고, 이동하고, 취소하고, 저장하는 순간마다 버튼은 행동의 접점이 됩니다. 좋은 버튼은 사용자가 “무엇을 할 수 있는지”를 분명하게 알려주고, 그 행동의 결과를 예측할 수 있게 돕습니다.
버튼 디자인에서 중요한 것은 예쁘게 보이는 것만이 아닙니다. 버튼은 눌러야 할 것처럼 보여야 하고, 어떤 행동을 수행하는지 명확해야 하며, 현재 상태와 우선순위를 시각적으로 전달해야 합니다. 같은 버튼이라도 색상, 크기, 위치, 문구, 아이콘, 여백에 따라 사용자가 느끼는 중요도와 행동 가능성은 크게 달라집니다.
{groups.map((group) => (
{group.title}
{group.sections.map((section) => (
{section.title}
{section.previews.map((example) => )}
{section.paragraphs.map((paragraph) =>
{paragraph}
)}
))}
))}
>
)
}
export { ButtonGuidelines }
```
## Source: components/button-guideline-preview.tsx
```tsx
"use client"
import { useId, useState, type CSSProperties, type ReactNode, type ComponentProps } from "react"
import { Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"
import { CornerComparison } from "@/components/corner-comparison"
import { useLocale } from "@/components/locale-provider"
function ExampleButton({ children = "저장", className = "", ...props }: ComponentProps) {
const { t } = useLocale()
const [pressed, setPressed] = useState(false)
return
}
function Comparison({ items }: { items: { label: string; preview: ReactNode }[] }) {
const { t } = useLocale()
return
{items.map((item) =>
{item.preview}
{t(item.label)}
)}
}
function ButtonGuidelinePreview({ example }: { example: string }) {
const { t } = useLocale()
const id = useId()
const [amount, setAmount] = useState(example === "radius" ? 22 : 12)
const [clicked, setClicked] = useState(false)
function range(label: string, min: number, max: number) {
return
setAmount(value)} />
}
function sample(style?: CSSProperties, label = "저장") {
return {label}
}
let content
switch (example) {
case "shape":
content = ({ label, preview: }))} />
break
case "size":
content = ({ label, preview: }))} />
break
case "radius":
content = <>{sample({ width: 160, height: 64, borderRadius: amount })}{range("곡률", 0, 32)}>
break
case "corners":
content =
break
case "padding":
content = 저장 },
{ label: "자연스러운 여백", preview: 저장 },
{ label: "과한 여백", preview: 저장 },
]} />
break
case "color":
content = ({ label, preview: sample({ background: ["var(--primary)", "var(--foreground)", "var(--destructive)"][index], color: index === 1 ? "var(--background)" : "white" }, index === 2 ? "삭제" : "저장") }))} />
break
case "color-hierarchy":
case "hierarchy":
content = ({ label, preview: {example === "hierarchy" && }{t(example === "hierarchy" ? "추가" : "저장")} }))} />
break
case "word":
content =
break
case "style":
content = ({ label, preview: }))} />
break
case "layer":
content = ({ label, preview: {t("추가")}{index === 1 && } }))} />
break
case "icon":
content = {t("추가")} },
{ label: "뒤", preview: {t("추가")} },
{ label: "아이콘만", preview: },
]} />
break
}
return
{t("원형과 연속형 모서리의 위쪽 접합부 확대 비교")}{t("위의 완전히 둥근 두 버튼과 동일한 모서리를 같은 배율로 확대해 겹쳐 표시합니다. 원형은 중립색 점선, 연속형은 파란 실선입니다.")}
{shapes.map((shape, index) => (
))}
{t("원형 모서리")}{t("연속형 모서리")}
)
}
export { CornerComparison }
```
## Source: components/tilt-card.tsx
```tsx
"use client"
/* Adapted from Bencho's Tilt card, MIT licensed.
* Copyright (c) 2026 Lorenzo Cabra
* https://bencho.dev/licence
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react"
import "./tilt-card.css"
// Bencho's photographs are not included. This replacement is by Calvin Mano,
// under the Unsplash License: https://unsplash.com/photos/CXS27RrJObQ
const DEFAULT_IMAGE = "https://images.unsplash.com/photo-1587405254461-abd1d1c7440e?auto=format&fit=crop&w=780&q=85"
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const mix = (a: number, b: number, t: number) => a + (b - a) * t
/* Frames, not milliseconds. Raising damping to dt means a dropped frame
decays the same energy as the frames it replaced. The loop parks as soon
as both the delta and velocity settle instead of running forever. */
const springOf = (tune: number) => ({
k: 0.08 + (tune / 100) * 0.16,
d: 0.62 + (tune / 100) * 0.2,
})
/* The pair is chosen by damping ratio: zeta = -ln(d) / (2 * sqrt(k)).
0 is heavy with little ring, 50 is the original tuning, and 100 is lively
with visible rebounds. Units matter: work in 0..100 so a 0.02 snap
threshold does not stop a normalised 0..1 value before it visibly moves. */
function useSpring(target: number, tune = 50) {
const [at, setAt] = useState(target)
const cur = useRef(target)
const vel = useRef(0)
useEffect(() => {
const { k, d } = springOf(tune)
let raf = 0
let prev = 0
const tick = (time: number) => {
const dt = prev ? clamp((time - prev) / 16.67, 0, 2.5) : 1
prev = time
vel.current += (target - cur.current) * k * dt
vel.current *= Math.pow(d, dt)
cur.current += vel.current * dt
if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
cur.current = target
vel.current = 0
setAt(target)
return
}
setAt(cur.current)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
// Tune belongs here too: changing it mid-flight resumes from the refs.
}, [target, tune])
return at
}
const motionQuery = "(prefers-reduced-motion: reduce)"
function subscribeMotion(listener: () => void) {
const media = window.matchMedia(motionQuery)
media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
}
const getMotionPreference = () => window.matchMedia(motionQuery).matches
const getServerMotionPreference = () => false
/* Perspective 800 is about two and a half original card heights back:
noticeable foreshortening without a fisheye. A 14px retreat reads as a
gentle press; beyond about 20px it starts reading as a zoom instead. */
const DEPTH = 800
const SINK = 14
type TiltCardProps = {
tilt?: number
corner?: number
shade?: number
width?: number
imageSrc?: string
label?: string
}
export function TiltCard({
tilt = 10,
corner = 20,
shade = 60,
width = 260,
imageSrc = DEFAULT_IMAGE,
label = "Tilt card",
}: TiltCardProps = {}) {
const skin = useRef(null)
const [at, setAt] = useState({ x: 0, y: 0 })
const [on, setOn] = useState(false)
const reducedMotion = useSyncExternalStore(subscribeMotion, getMotionPreference, getServerMotionPreference)
/* Spring the rotation, light intensity, and outer shadow to give the card mass.
The small pointer shade uses the raw position instead, so it stays
attached to the cursor while the card itself settles. */
const springX = useSpring(on && !reducedMotion ? at.x * 100 : 0)
const springY = useSpring(on && !reducedMotion ? at.y * 100 : 0)
const springLight = useSpring(on && !reducedMotion ? 100 : 0)
const sx = reducedMotion ? 0 : springX / 100
const sy = reducedMotion ? 0 : springY / 100
const lit = reducedMotion ? 0 : clamp(springLight / 100, 0, 1)
const max = clamp(tilt, 0, 20)
/* It sinks, it does not lift: the edge under the pointer goes AWAY.
Top means positive rotateX, right means positive rotateY. The signs
are the idea, not a tuning detail. The pointer casts a local shade below
itself while the highlight stays above, keeping the light overhead. */
const rx = -sy * max
const ry = sx * max
const px = ((at.x + 1) / 2) * 100
const py = ((at.y + 1) / 2) * 100 + 6
const dark = (clamp(shade, 0, 100) / 100) * 0.22 * lit
const rim = (clamp(shade, 0, 100) / 100) * 0.34 * lit
const shadowScale = Math.max(0, width) / 260
function track(event: PointerEvent) {
if (reducedMotion || !skin.current) return
// Measure the fixed frame, never the rotating surface beneath it.
const rect = skin.current.getBoundingClientRect()
setAt({
x: clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1),
y: clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1),
})
setOn(true)
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") {
setOn(false)
return
}
const directions: Record = {
ArrowLeft: [-0.35, 0], ArrowRight: [0.35, 0],
ArrowUp: [0, -0.35], ArrowDown: [0, 0.35],
}
const direction = directions[event.key]
if (!direction || reducedMotion) return
event.preventDefault()
setAt((value) => {
const position = on ? value : { x: 0, y: 0 }
return { x: clamp(position.x + direction[0], -1, 1), y: clamp(position.y + direction[1], -1, 1) }
})
setOn(true)
}
return (
)
}
```
## Source: components/tilt-card.css
```css
/* The fixed frame owns perspective and pointer measurement. Rotation, light,
and shadow are written from one spring position; no extra transitions. */
.compass-tilt {
position: relative;
display: block;
flex-shrink: 0;
max-width: 100%;
aspect-ratio: 13 / 16;
border: 0;
border-radius: var(--tilt-corner);
padding: 0;
background: transparent;
outline: none;
touch-action: pan-y;
}
.compass-tilt:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 6px;
}
.compass-tilt-card {
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--secondary);
background-size: cover;
background-position: center;
overflow: hidden;
pointer-events: none;
/* No permanent will-change: the browser promotes the surface only when
needed, rather than keeping every gallery card on its own layer. */
}
.compass-tilt-sheen {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
```
## Source: lib/button-guideline-navigation.ts
```ts
export function buttonGuidelineSectionId(groupId: string, example: string) {
return `${groupId}-${example}`
}
const groups = [
{
id: "button-graphic",
label: "그래픽",
items: [
["모양", "shape"],
["크기", "size"],
["곡률", "radius"],
["연속형 모서리", "corners"],
["여백", "padding"],
["색", "color"],
["색 위계", "color-hierarchy"],
["글 쓰기", "word"],
["스타일", "style"],
],
},
{
id: "button-composition",
label: "컴포지션",
items: [
["레이어", "layer"],
["조직화", "icon"],
["위계", "hierarchy"],
],
},
]
export const buttonGuidelineToc = [
{ label: "가이드", href: "#guidelines", depth: 0 },
...groups.flatMap((group) => [
{ label: group.label, href: `#${group.id}`, depth: 1 },
...group.items.map(([label, example]) => ({
label,
href: `#${buttonGuidelineSectionId(group.id, example)}`,
depth: 2,
})),
]),
]
```
## Source: lib/input-guidelines.ts
```ts
type GuidelineText = readonly [ko: string, en: string]
type InputGuideline = {
whenToUse: readonly GuidelineText[]
whenNotToUse: readonly GuidelineText[]
}
export const inputGuidelines: Record = {
button: {
whenToUse: [
[
"저장, 제출, 추가처럼 사용자가 명시적으로 실행해야 하는 행동이 있을 때 사용합니다.",
"Use for actions people explicitly initiate, such as saving, submitting, or adding an item.",
],
[
"다음 단계로 진행하거나 현재 작업을 완료·취소하는 결정을 받을 때 사용합니다.",
"Use when people decide to continue, finish, or cancel a task.",
],
[
"실행 전후의 대기, 완료, 실패 상태를 같은 행동의 맥락에서 알려야 할 때 사용합니다.",
"Use when an action needs to communicate waiting, completion, or failure in context.",
],
],
whenNotToUse: [
[
"다른 문서나 주소로 단순히 이동할 때는 링크를 사용합니다.",
"Use a link for straightforward navigation to another document or address.",
],
[
"설정을 켜거나 끄는 선택에는 토글을, 여러 선택지를 고르는 데에는 단일·다중 선택을 사용합니다.",
"Use a switch for an on/off setting and single or multiple selection for choosing options.",
],
],
},
input: {
whenToUse: [
[
"이름, 이메일, 검색어처럼 사용자가 직접 내용을 작성하거나 수정할 때 사용합니다.",
"Use for entering or editing text such as a name, email address, or search query.",
],
[
"미리 정한 선택지로 표현하기 어렵고 자유로운 답변이 필요할 때 사용합니다.",
"Use when predefined options cannot adequately represent the answer.",
],
[
"한 줄 안에서 읽고 수정할 수 있는 짧은 내용을 받을 때 사용합니다.",
"Use for short content that can be read and edited on one line.",
],
],
whenNotToUse: [
[
"긴 설명이나 여러 문단을 작성해야 할 때는 여러 줄 입력 영역을 사용합니다.",
"Use a multiline text area for long descriptions or multiple paragraphs.",
],
[
"답이 제한된 목록에 있을 때는 드롭다운이나 단일 선택을 사용합니다.",
"Use a dropdown or single selection when answers come from a finite list.",
],
[
"비밀번호, 전화번호, 인증 코드처럼 입력 규칙이 뚜렷한 정보는 전용 입력을 사용합니다.",
"Use dedicated inputs for passwords, phone numbers, and verification codes.",
],
],
},
select: {
whenToUse: [
[
"정해진 목록에서 하나를 선택해야 하고, 모든 선택지를 펼치기에는 공간이 부족할 때 사용합니다.",
"Use to choose one option from a predefined list when showing every option would take too much space.",
],
[
"팀, 지역, 정렬 방식처럼 짧은 이름으로 구분할 수 있는 항목을 선택할 때 사용합니다.",
"Use for choices with distinct short labels, such as teams, regions, or sort orders.",
],
[
"현재 선택을 간결하게 보여주고 필요할 때 다른 선택지를 확인하게 할 때 사용합니다.",
"Use to keep the current selection compact while making alternatives available on demand.",
],
],
whenNotToUse: [
[
"선택지가 적고 서로 비교하는 것이 중요하면 단일 선택으로 모두 보여줍니다.",
"Use single selection to show all options when there are few choices and comparison matters.",
],
[
"목록이 길어 항목을 찾기 어려우면 검색할 수 있는 선택기를 사용합니다.",
"Use a searchable picker when the list is too long to scan comfortably.",
],
[
"여러 항목을 동시에 고르거나 자유롭게 작성해야 한다면 다중 선택이나 텍스트 필드를 사용합니다.",
"Use multiple selection for several choices, or a text field for an open-ended answer.",
],
],
},
checkbox: {
whenToUse: [
[
"서로 독립적인 선택지에서 원하는 항목을 여러 개 선택할 수 있을 때 사용합니다.",
"Use when people can select several independent options.",
],
[
"알림 수단이나 관심 분야처럼 아무것도 선택하지 않는 것도 허용될 때 사용합니다.",
"Use for preferences such as notification channels or interests where selecting nothing can be valid.",
],
[
"동의 여부처럼 제출 전에 확인할 항목을 선택·해제할 때 사용합니다.",
"Use to check or uncheck an acknowledgement before submitting a form.",
],
],
whenNotToUse: [
[
"반드시 하나만 고를 수 있는 선택지에는 단일 선택을 사용합니다.",
"Use single selection when only one option can be chosen.",
],
[
"선택 즉시 적용되는 켜기·끄기 설정에는 토글을 사용합니다.",
"Use a switch for an on/off setting that takes effect immediately.",
],
],
},
"radio-group": {
whenToUse: [
[
"배달, 픽업, 방문처럼 서로 함께 선택할 수 없는 항목에서 하나를 고를 때 사용합니다.",
"Use to choose one mutually exclusive option, such as delivery, pickup, or an in-person visit.",
],
[
"선택지가 적고 모든 항목을 바로 비교할 수 있어야 할 때 사용합니다.",
"Use when there are few options and people need to compare all of them at a glance.",
],
[
"하나의 답이 필요하며 현재 선택이 항상 분명해야 할 때 사용합니다.",
"Use when one answer is required and the current selection should remain clear.",
],
],
whenNotToUse: [
[
"여러 항목을 함께 고를 수 있다면 다중 선택을 사용합니다.",
"Use multiple selection when more than one option may be chosen.",
],
[
"선택지가 많아 화면을 길게 차지하면 드롭다운이나 검색 가능한 선택기를 사용합니다.",
"Use a dropdown or searchable picker when many options would make the page too long.",
],
[
"하나의 설정을 켜거나 끄는 용도라면 토글을 사용합니다.",
"Use a switch to turn a single setting on or off.",
],
],
},
slider: {
whenToUse: [
[
"밝기, 음량처럼 범위 안에서 상대적인 크기를 조절할 때 사용합니다.",
"Use to adjust a relative level within a range, such as brightness or volume.",
],
[
"정확한 숫자보다 대략적인 위치나 강도가 중요할 때 사용합니다.",
"Use when an approximate position or intensity matters more than an exact number.",
],
[
"조절 결과를 바로 보거나 들으며 값을 탐색할 수 있을 때 사용합니다.",
"Use when people can explore values with immediate visual or audio feedback.",
],
],
whenNotToUse: [
[
"좌석 수나 주문 수량처럼 정확한 숫자가 중요하면 숫자 스테퍼를 사용합니다.",
"Use a number stepper when an exact quantity, such as seats or items, matters.",
],
[
"전화번호나 인증 코드처럼 숫자로 이루어진 문자열은 전용 입력을 사용합니다.",
"Use dedicated inputs for numeric strings such as phone numbers or verification codes.",
],
[
"값마다 의미가 다른 몇 가지 선택이라면 단일 선택을 사용합니다.",
"Use single selection for a few choices with distinct meanings rather than a continuous range.",
],
],
},
switch: {
whenToUse: [
[
"알림이나 자동 저장처럼 하나의 설정을 켜거나 끌 때 사용합니다.",
"Use to turn a single setting on or off, such as notifications or autosave.",
],
[
"변경이 즉시 적용되고 현재 상태를 계속 확인할 수 있을 때 사용합니다.",
"Use when changes take effect immediately and the current state remains visible.",
],
[
"켜짐과 꺼짐의 의미가 명확한 두 가지 상태를 표현할 때 사용합니다.",
"Use for two states whose on and off meanings are unambiguous.",
],
],
whenNotToUse: [
[
"별도 제출 버튼을 눌러야 적용되는 선택이나 동의 항목에는 다중 선택을 사용합니다.",
"Use a checkbox for acknowledgements or choices that are committed by submitting a form.",
],
[
"여러 방식 중 하나를 고르는 상황에는 단일 선택이나 세그멘티드 컨트롤을 사용합니다.",
"Use single selection or a segmented control to choose one of several modes.",
],
[
"삭제나 전송처럼 한 번 실행하는 행동에는 버튼을 사용합니다.",
"Use a button for one-time actions such as deleting or sending.",
],
],
},
"password-input": {
whenToUse: [
[
"로그인이나 비밀번호 변경처럼 다른 사람이 읽지 못하도록 입력 내용을 가려야 할 때 사용합니다.",
"Use for sign-in or password changes when entered characters should be concealed from onlookers.",
],
[
"입력 내용을 직접 확인할 수 있는 표시·숨기기 기능이 필요할 때 사용합니다.",
"Use when people need a show/hide control to check what they have entered.",
],
],
whenNotToUse: [
[
"이름이나 이메일처럼 가릴 필요가 없는 정보에는 텍스트 필드를 사용합니다.",
"Use a text field for information that does not need concealment, such as a name or email address.",
],
[
"일회성 인증 번호를 받을 때는 코드 입력을 사용합니다.",
"Use a code input for a one-time verification code.",
],
],
},
"phone-input": {
whenToUse: [
[
"연락이나 인증을 위해 실제 전화번호를 받을 때 사용합니다.",
"Use to collect an actual phone number for contact or verification.",
],
[
"국가번호와 지역별 번호 형식을 구분해 입력을 도와야 할 때 사용합니다.",
"Use when country codes and regional number formats need to be handled clearly.",
],
],
whenNotToUse: [
[
"수량이나 금액처럼 계산할 숫자에는 숫자 스테퍼나 숫자 입력을 사용합니다.",
"Use a number stepper or numeric field for quantities and amounts used in calculations.",
],
[
"전화번호가 아닌 회원 번호나 인증 코드는 해당 형식에 맞는 입력을 사용합니다.",
"Use an appropriate text or code input for membership numbers and verification codes.",
],
],
},
"code-input": {
whenToUse: [
[
"문자나 이메일로 받은 6자리 인증 코드를 입력할 때 사용합니다.",
"Use to enter a six-digit verification code received by text message or email.",
],
[
"정해진 자리 수를 확인하면서 숫자 코드를 입력하거나 한 번에 붙여 넣을 때 사용합니다.",
"Use when people need to see the required digit count and enter or paste a numeric code.",
],
],
whenNotToUse: [
[
"길이가 정해지지 않은 비밀번호나 긴 식별자에는 비밀번호 입력이나 텍스트 필드를 사용합니다.",
"Use a password input or text field for variable-length passwords or long identifiers.",
],
[
"계산하거나 증감하는 숫자는 코드가 아니므로 숫자 스테퍼를 사용합니다.",
"Use a number stepper for numbers that are calculated or incremented, rather than treated as codes.",
],
],
},
"number-stepper": {
whenToUse: [
[
"주문 수량이나 인원처럼 정확한 값이 중요하고 최소·최대 범위가 있는 숫자를 받을 때 사용합니다.",
"Use for bounded quantities where the exact value matters, such as item counts or party sizes.",
],
[
"현재 값에서 조금씩 늘리거나 줄이는 조작이 자주 필요할 때 사용합니다.",
"Use when people frequently increase or decrease a value in small steps.",
],
[
"개, 명처럼 단위가 분명한 수량을 선택할 때 사용합니다.",
"Use for quantities with clear units, such as items or people.",
],
],
whenNotToUse: [
[
"정확한 숫자보다 대략적인 정도를 조절하는 경우에는 슬라이더를 사용합니다.",
"Use a slider when an approximate level matters more than an exact number.",
],
[
"전화번호나 인증 코드처럼 앞자리 0도 의미가 있는 숫자 문자열에는 전용 입력을 사용합니다.",
"Use dedicated inputs for numeric strings such as phone numbers or codes, where leading zeros can matter.",
],
],
},
"date-picker": {
whenToUse: [
[
"예약일, 방문일, 마감일처럼 달력상의 특정 날짜를 선택할 때 사용합니다.",
"Use to choose a specific calendar date, such as a booking, visit, or due date.",
],
[
"요일과 주변 날짜를 확인하며 일정을 결정할 때 사용합니다.",
"Use when people need the weekday and surrounding dates to decide on a schedule.",
],
[
"선택할 수 없는 날짜나 허용되는 기간을 알려야 할 때 사용합니다.",
"Use when unavailable dates or an allowed date range need to be communicated.",
],
],
whenNotToUse: [
[
"시각만 정하는 경우에는 시간 선택기를 사용합니다.",
"Use a time picker when only the time of day is needed.",
],
[
"시작일과 종료일을 함께 고르는 경우에는 날짜 범위 선택기를 사용합니다.",
"Use a date-range picker when choosing both a start and an end date.",
],
],
},
"time-picker": {
whenToUse: [
[
"예약 시각이나 알림 시각처럼 하루 중 특정 시간을 정할 때 사용합니다.",
"Use to choose a time of day, such as an appointment or reminder time.",
],
[
"오전·오후, 시, 분을 구분하면서 시간을 조절할 때 사용합니다.",
"Use when people need to adjust the period, hour, and minute separately.",
],
[
"5분 단위처럼 정해진 간격의 시각을 선택하게 할 때 사용합니다.",
"Use when time choices follow a fixed interval, such as five-minute steps.",
],
],
whenNotToUse: [
[
"날짜를 정하는 경우에는 날짜 선택기를 사용합니다.",
"Use a date picker to choose a calendar date.",
],
[
"30분, 2시간처럼 걸리는 시간을 받는 경우에는 단위를 표시한 수량 입력을 사용합니다.",
"Use a quantity input with units for durations such as 30 minutes or two hours.",
],
],
},
"file-drop": {
whenToUse: [
[
"사용자가 가진 문서나 이미지를 첨부해야 할 때 사용합니다.",
"Use when people need to attach documents or images they already have.",
],
[
"파일을 끌어 놓는 방식과 파일 선택 버튼을 함께 제공할 때 사용합니다.",
"Use when drag-and-drop and a file selection button should offer equivalent ways to attach a file.",
],
],
whenNotToUse: [
[
"파일 대신 짧은 텍스트만 받으면 되는 경우에는 텍스트 필드를 사용합니다.",
"Use a text field when a short text answer is sufficient instead of a file.",
],
[
"음성을 새로 녹음하거나 서명을 직접 작성해야 한다면 해당 전용 입력을 사용합니다.",
"Use dedicated recording or signature inputs when people need to create audio or draw a signature.",
],
],
},
"voice-recorder": {
whenToUse: [
[
"음성 메모나 발음처럼 소리 자체를 기록해야 할 때 사용합니다.",
"Use when the sound itself needs to be captured, such as a voice memo or pronunciation.",
],
[
"녹음을 시작·중지하고 결과를 들어본 뒤 사용자가 제출 여부를 결정할 수 있을 때 사용합니다.",
"Use when people can start, stop, and review a recording before deciding whether to submit it.",
],
],
whenNotToUse: [
[
"정확한 문구를 입력받거나 조용한 환경에서 사용해야 한다면 텍스트 입력도 제공합니다.",
"Offer text entry when exact wording matters or people need to use the interface quietly.",
],
[
"이미 녹음한 파일을 첨부하는 목적이라면 파일 첨부를 사용합니다.",
"Use file attachment when people already have a recording to provide.",
],
],
},
"signature-input": {
whenToUse: [
[
"사용자가 손으로 작성한 서명 이미지를 직접 남겨야 할 때 사용합니다.",
"Use when people need to provide a hand-drawn signature image.",
],
[
"터치나 마우스로 작성하고 지우거나 다시 쓴 뒤 결과를 확인할 수 있을 때 사용합니다.",
"Use when people can draw with touch or a mouse, clear mistakes, and review the result.",
],
],
whenNotToUse: [
[
"단순한 동의 확인만 필요하면 체크 항목과 명확한 안내 문구를 사용합니다.",
"Use a checkbox and clear explanatory text when only an acknowledgement is needed.",
],
[
"이름을 텍스트로 받으면 충분하거나 기존 서명 파일을 받는 경우에는 텍스트 필드나 파일 첨부를 사용합니다.",
"Use a text field when a typed name is sufficient, or file attachment for an existing signature image.",
],
],
},
}
export const inputGuidelineToc = [
{ label: "가이드", href: "#guidelines", depth: 0 },
{ label: "사용할 때", href: "#when-to-use", depth: 1 },
{ label: "사용하지 않을 때", href: "#when-not-to-use", depth: 1 },
]
export function getInputGuideline(slug: string) {
return Object.hasOwn(inputGuidelines, slug)
? inputGuidelines[slug]
: undefined
}
```
## Source: components/input-usage-guide.tsx
```tsx
"use client"
import { useLocale } from "@/components/locale-provider"
import { getInputGuideline } from "@/lib/input-guidelines"
export function InputUsageGuide({ slug }: { slug: string }) {
const { locale } = useLocale()
const guideline = getInputGuideline(slug)
if (!guideline) return null
const language = locale === "ko" ? 0 : 1
const sections = [
{
id: "when-to-use",
title: ["사용할 때", "When to use"],
items: guideline.whenToUse,
},
{
id: "when-not-to-use",
title: ["사용하지 않을 때", "When not to use"],
items: guideline.whenNotToUse,
},
]
return (
)
}
function AccordionPreview() {
const { t } = useLocale()
const items = [
["배송", "배송은 영업일 기준 2~3일 소요됩니다."],
["교환·반품", "수령 후 7일 이내에 요청할 수 있습니다."],
["결제", "카드와 간편 결제를 사용할 수 있습니다."],
]
return (
)
}
export { TimePicker }
export type { TimePickerProps }
```
## Source: lib/extended-catalog.ts
```ts
const inputItems = [
[
"password-input",
"비밀번호 입력",
"Password input",
"비밀번호를 입력하고 필요할 때 표시 여부를 바꿉니다.",
"Enter a password and reveal it when needed.",
"입력 내용을 기본적으로 가리고, 표시 전환 버튼에 명확한 이름을 제공합니다.",
"Mask the value by default and clearly label the visibility control.",
],
[
"phone-input",
"전화번호 입력",
"Phone input",
"전화번호를 읽기 쉬운 형식으로 입력합니다.",
"Enter a phone number in a readable format.",
"숫자 키보드를 제공하고 국가별 형식을 고려합니다. 입력 중에는 오류 안내를 서두르지 않습니다.",
"Offer a numeric keyboard and account for country formats. Avoid premature errors while typing.",
],
[
"code-input",
"6자리 코드 입력",
"6-digit code input",
"인증을 위한 일회용 숫자 코드를 입력합니다.",
"Enter a one-time numeric verification code.",
"코드 전체를 붙여넣을 수 있게 하고, 입력칸 이동과 삭제를 자연스럽게 지원합니다.",
"Allow pasting the complete code and support natural navigation and deletion.",
],
[
"number-stepper",
"숫자 스테퍼",
"Number stepper",
"정해진 범위 안에서 수량을 늘리거나 줄입니다.",
"Increase or decrease a quantity within a range.",
"최솟값과 최댓값에서 해당 버튼을 비활성화하고 직접 입력도 허용합니다.",
"Disable controls at the limits and allow direct entry.",
],
[
"date-picker",
"날짜 선택기",
"Date picker",
"일정을 위해 날짜를 선택합니다.",
"Choose a date for an event.",
"날짜 형식을 명확히 표시하고 선택한 날짜를 편집하거나 지울 수 있게 합니다.",
"Show the date format clearly and allow editing or clearing the selection.",
],
[
"time-picker",
"시간 선택기",
"Time picker",
"오전·오후, 시, 분을 각각 조절해 시간을 선택합니다.",
"Choose a time by adjusting the period, hour and minute separately.",
"선택된 시간을 중앙에 표시하고 터치·마우스·키보드로 같은 선택을 할 수 있게 합니다.",
"Center the selected time and support equivalent touch, mouse and keyboard interaction.",
],
[
"file-drop",
"드래그 앤 드롭",
"Drag & drop",
"파일을 끌어 놓거나 찾아 선택합니다.",
"Drop a file or browse to select it.",
"끌기 외에도 키보드로 파일을 선택할 수 있게 하고, 허용 형식과 용량 초과를 안내합니다.",
"Provide a keyboard-accessible alternative and explain unsupported formats or size limits.",
],
[
"voice-recorder",
"음성 녹음",
"Voice recorder",
"음성을 녹음하고 다시 듣습니다.",
"Record audio and listen back.",
"시작·중지 상태를 구분하고 권한 거부 시 복구 방법을 안내합니다. 녹음은 명시적인 동작으로만 시작합니다.",
"Distinguish start and stop states and handle denied permission. Start recording only after an explicit action.",
],
[
"signature-input",
"서명 입력",
"Signature input",
"손가락이나 마우스로 서명을 작성합니다.",
"Draw a signature with a finger or mouse.",
"충분한 작성 영역과 지우기 기능을 제공합니다. 서명 없이 진행할 수 없는 상황은 명확히 알립니다.",
"Provide ample drawing space and a clear action. Explain when a signature is required.",
],
] as const
const additionalItems = [
[
"tree-view",
"트리뷰",
"Tree view",
"폴더와 하위 항목의 계층을 탐색합니다.",
"Explore a hierarchy of folders and items.",
"펼침 상태와 선택 상태를 구분하고 키보드로도 항목에 접근할 수 있게 합니다.",
"Distinguish expansion from selection and make items keyboard accessible.",
],
[
"accordion",
"아코디언",
"Accordion",
"관련 내용을 필요한 때 펼쳐 확인합니다.",
"Expand related information when needed.",
"제목만으로 내용을 예측할 수 있게 하고, 열린 상태를 명확하게 표시합니다.",
"Use descriptive headings and clearly show expanded states.",
],
[
"carousel",
"캐러셀",
"Carousel",
"여러 이미지를 한 영역에서 순서대로 탐색합니다.",
"Browse a sequence of images in one area.",
"자동 이동을 강제하지 않고 버튼·드래그·키보드로 이동할 수 있게 합니다.",
"Avoid forced autoplay and support buttons, dragging, and keyboard navigation.",
],
[
"table-of-contents",
"목차",
"Table of Contents",
"문서의 제목 위계를 보여주고 원하는 섹션으로 이동합니다.",
"Show a document's heading hierarchy and navigate to a section.",
"본문의 제목과 같은 이름을 사용하고 현재 위치와 하위 제목의 깊이를 명확히 구분합니다.",
"Match document headings and clearly distinguish the current location and nested levels.",
],
[
"image-gallery",
"갤러리",
"Gallery",
"이미지 모음을 비교하고 크게 확인합니다.",
"Compare a collection of images and open a larger view.",
"동일한 비율로 썸네일을 배치하고, 확대한 이미지를 쉽게 닫을 수 있게 합니다.",
"Keep thumbnails consistent and make enlarged images easy to dismiss.",
],
[
"calendar",
"캘린더",
"Calendar",
"월 단위로 날짜를 탐색하고 선택합니다.",
"Browse and select dates by month.",
"현재 날짜와 선택 날짜를 구분하고 월 이동을 키보드로도 지원합니다.",
"Distinguish today from the selected date and support keyboard navigation.",
],
[
"map",
"지도",
"Map",
"장소와 주변 지리를 탐색합니다.",
"Explore places and their surroundings.",
"확대·축소와 이동을 제공하되 페이지 스크롤을 방해하지 않도록 합니다.",
"Offer zoom and pan without trapping page scrolling.",
],
[
"table",
"테이블",
"Table",
"여러 항목의 정보를 같은 기준으로 비교합니다.",
"Compare records using consistent columns.",
"열 제목과 정렬 방향을 명확히 표시하고, 숫자는 비교하기 쉽게 정렬합니다.",
"Label columns and sort direction clearly and align numbers for comparison.",
],
[
"timeline",
"단계 진행 세로형",
"Vertical Step Progress",
"시간 순서에 따라 사건과 진행 상황을 살펴봅니다.",
"Explore events and progress in chronological order.",
"시간과 상태를 함께 표시하고, 색상 외에도 텍스트로 진행 상황을 구분합니다.",
"Show time and status together and communicate progress beyond color alone.",
],
] as const
export const extendedInputItems = [...inputItems.filter(([slug]) => slug !== "file-drop"), ...inputItems.filter(([slug]) => slug === "file-drop")].map(([slug, label]) => ({
slug,
label,
}))
const additionalCatalogItems = additionalItems.map(([slug, label]) => ({
slug,
label,
}))
export const extendedNavigationItems = additionalCatalogItems.filter(
({ slug }) =>
["tree-view", "accordion", "carousel"].includes(slug)
)
export const extendedLayoutItems = additionalCatalogItems.filter(
({ slug }) => slug === "image-gallery"
)
export const extendedModuleItems = additionalCatalogItems.filter(({ slug }) =>
["calendar", "map", "table"].includes(slug)
)
export const extendedStatusItems = additionalCatalogItems.filter(
({ slug }) => slug === "timeline"
)
export const extendedComponentDetails = Object.fromEntries(
[...inputItems, ...additionalItems].map(
([slug, , , description, descriptionEn, usage, usageEn]) => [
slug,
{ description, descriptionEn, usage, usageEn },
]
)
)
export const extendedCatalogTranslations = Object.fromEntries(
[...inputItems, ...additionalItems].map(([, ko, en]) => [ko, en])
)
```
## Source: components/foundation-preview.tsx
```tsx
"use client"
import Link from "next/link"
import { useState } from "react"
import { useLocale } from "@/components/locale-provider"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { DialogPreview } from "@/components/component-previews"
import { InputPreview } from "@/components/catalog-previews"
function FoundationPreview({ slug }: { slug: string }) {
const { t } = useLocale()
const [columns, setColumns] = useState("2")
const [saved, setSaved] = useState(false)
if (slug === "layers") return
if (slug === "input-output") return
if (slug === "writing")
return (
{t("변경 사항을 저장합니다.")}
{saved ? t("설정이 저장되었습니다.") : null}
)
if (slug === "layout")
return (
{
if (value) setColumns(value)
}}
variant="outline"
aria-label={t("레이아웃")}
>
123
)
}
export { FoundationPreview }
```
## Source: lib/docs-catalog.ts
```ts
import { extendedInputItems, extendedNavigationItems, extendedLayoutItems, extendedStatusItems, extendedComponentDetails, extendedCatalogTranslations } from "@/lib/extended-catalog"
export const componentGroups = [
{
title: "Input",
items: [
{ label: "버튼", slug: "button" },
{ label: "토글", slug: "switch" },
{ label: "슬라이더", slug: "slider" },
{ label: "Single Selection", slug: "radio-group" },
{ label: "Multiple Selection", slug: "checkbox" },
{ label: "드롭다운", slug: "select" },
{ label: "툴 바", slug: "toolbar" },
{ label: "텍스트 필드", slug: "input" },
{ label: "검색", slug: "search" },
...extendedInputItems,
],
},
{
title: "Layout",
items: [
{ label: "카드", slug: "card" },
{ label: "이미지 카드", slug: "image-card" },
{ label: "리스트", slug: "list" },
{ label: "테이블", slug: "table" },
{ label: "그리드", slug: "grid" },
...extendedLayoutItems,
...extendedNavigationItems.filter((item) => item.slug === "carousel"),
{ label: "유동형 레이아웃", slug: "fluid-layout" },
{ label: "인라인 확장", slug: "inline-expansion" },
{ label: "밀어내는 패널", slug: "push-panel" },
{ label: "팝오버", slug: "popover" },
{ label: "모달", slug: "dialog" },
{ label: "바텀 시트", slug: "bottom-sheet" },
{ label: "사이드 패널", slug: "side-panel" },
{ label: "토스트", slug: "toast" },
{ label: "플로팅 카드", slug: "alert-dialog" },
],
},
{
title: "Navigation",
items: [
{ label: "탭", slug: "classic-tabs" },
{ label: "세그멘티드 컨트롤", slug: "tabs" },
{ label: "칩 필터", slug: "filter" },
{ label: "네비게이션 바", slug: "navigation-bar" },
{ label: "브레드크럼", slug: "breadcrumb" },
{ label: "목차", slug: "table-of-contents" },
...["accordion", "tree-view"].flatMap((slug) => extendedNavigationItems.filter((item) => item.slug === slug)),
{ label: "점 페이지네이션", slug: "dot-pagination" },
{ label: "페이지네이션", slug: "pagination" },
],
},
{
title: "Status",
items: [
{ label: "태그", slug: "tag" },
{ label: "뱃지", slug: "badge" },
{ label: "과정 표시", slug: "progress" },
{ label: "스켈레톤", slug: "skeleton" },
{ label: "프로그레스 바", slug: "progress-bar" },
{ label: "아바타", slug: "avatar" },
{ label: "아바타 모음", slug: "avatar-group" },
{ label: "원형 게이지", slug: "goal-ring" },
{ label: "습관 추적", slug: "challenge" },
{ label: "활동 히트맵", slug: "contributions" },
{ label: "단계 진행 가로형", slug: "step-progress" },
...extendedStatusItems,
],
},
]
export const componentDetails: Record<
string,
{ description: string; descriptionEn: string; usage: string; usageEn: string }
> = {
...extendedComponentDetails,
"image-card": {
description: "이미지를 배경으로 사용하고 하단에 제목과 태그를 배치합니다.",
descriptionEn: "Place a title and tag over a full-background image.",
usage: "하단으로 갈수록 블러를 높여 이미지의 분위기를 유지하면서 텍스트를 읽기 쉽게 합니다.",
usageEn: "Increase blur toward the bottom to preserve the image while keeping text readable.",
},
...Object.fromEntries([
["list", "관련 항목을 세로 목록으로 정리합니다.", "Arrange related items in a vertical list."],
["table", "같은 속성을 가진 데이터를 행과 열로 비교합니다.", "Compare data with shared attributes in rows and columns."],
["grid", "콘텐츠를 일정한 열과 간격으로 배치합니다.", "Arrange content in consistent columns with even spacing."],
["inline-expansion", "현재 흐름 안에서 상세 영역을 펼쳐 아래 콘텐츠를 밀어냅니다. 아코디언이나 추가 입력 영역에 사용합니다.", "Expand details in the document flow, pushing following content down. Use for accordions or additional form fields."],
["push-panel", "패널이 공간을 차지하면 기존 콘텐츠의 폭이 줄어듭니다. 화면을 덮지 않고 메뉴나 보조 작업을 함께 보여줍니다.", "A panel takes layout space and resizes the main content. Show navigation or supporting tasks without covering the page."],
["fluid-layout", "영역의 너비가 변하면 콘텐츠도 같은 비율로 늘어나거나 줄어듭니다.", "Content expands or contracts with the available width."],
["navigation-bar", "주요 화면 사이의 이동을 제공합니다.", "Navigate between main destinations."],
["toolbar", "현재 작업에 필요한 도구를 모읍니다.", "Group tools for the current task."],
["popover", "선택한 요소 가까이에 보조 정보를 엽니다.", "Open contextual information beside a trigger."],
["side-panel", "화면 옆에서 상세 정보와 보조 작업을 제공합니다.", "Show details and supporting tasks beside the page."],
["toast", "방금 수행한 작업의 결과를 잠시 알려줍니다.", "Briefly announce the result of an action."],
].map(([slug, description, descriptionEn]) => [slug, { description, descriptionEn, usage: description, usageEn: descriptionEn }])),
skeleton: {
description: "콘텐츠를 불러오는 동안 실제 요소의 형태와 위치를 미리 보여줍니다.",
descriptionEn: "Preview the shape and position of content while it loads.",
usage: "콘텐츠와 비슷한 크기로 자리를 확보해 로딩 전후의 화면 이동을 줄입니다. 움직임은 은은하게 유지합니다.",
usageEn: "Reserve space matching the content to reduce layout shifts. Keep the animation subtle.",
},
"progress-bar": {
description: "작업이 얼마나 진행되었는지 비율로 표시합니다.",
descriptionEn: "Show how much of a task has been completed.",
usage: "전체 작업량을 알 수 있는 업로드나 처리 과정에 사용합니다. 처리 중에는 최대 99%까지만 표시하고, 완료되면 100%를 보여주지 않고 다음 단계로 전환합니다. 진행률을 계산할 수 없다면 로딩 표시를 사용합니다.",
usageEn: "Use for uploads or tasks with a known total. Display at most 99% while processing, and move to the next step on completion without showing 100%. Use a loading indicator when progress cannot be measured.",
},
"step-progress": {
description: "여러 단계로 이루어진 과정의 현재 위치를 표시합니다.",
descriptionEn: "Show the current position in a multi-step process.",
usage: "현재 단계, 완료한 단계, 남은 단계를 구분합니다. 각 단계의 이름은 짧고 명확하게 표시합니다.",
usageEn: "Distinguish current, completed, and upcoming steps. Keep step labels short and clear.",
},
"video-playback": {
description: "동영상의 재생 상태와 현재 위치를 확인하고 제어합니다.",
descriptionEn: "View and control video playback and its current position.",
usage: "재생과 일시정지를 명확히 구분하고, 현재 시간과 전체 길이를 함께 표시합니다. 재생바로 원하는 구간을 탐색할 수 있게 합니다.",
usageEn: "Clearly distinguish play and pause, show elapsed and total time, and allow seeking to a specific position.",
},
"avatar-group": { description: "여러 참여자를 묶어 보여줍니다.", descriptionEn: "Show a group of participants.", usage: "참여자 수를 간결하게 표현합니다.", usageEn: "Show participants compactly." },
"goal-ring": { description: "목표 달성 비율을 원형으로 표시합니다.", descriptionEn: "Show goal progress in a ring.", usage: "달성률을 숫자와 함께 표시합니다.", usageEn: "Include a numeric progress value." },
challenge: { description: "일별 진행과 챌린지 달성을 표시합니다.", descriptionEn: "Show daily progress and challenge completion.", usage: "완료한 단계와 남은 단계를 구분합니다.", usageEn: "Distinguish completed and remaining steps." },
contributions: { description: "날짜별 활동량을 작은 칸으로 표시합니다.", descriptionEn: "Show daily activity as a contribution grid.", usage: "색상 농도로 활동량을 표현하고 각 날짜에 수치를 제공합니다.", usageEn: "Use color intensity and accessible daily counts." },
search: {
description: "키워드로 원하는 항목을 찾습니다.",
descriptionEn: "Find items using a keyword.",
usage: "입력에 따라 결과를 갱신하고 검색어 지우기와 결과 없음 상태를 제공합니다.",
usageEn: "Update results as people type and provide clearing and empty states.",
},
breadcrumb: {
description: "현재 위치와 상위 경로를 보여줍니다.",
descriptionEn: "Show the current location and its parent pages.",
usage: "상위 경로에는 링크를 제공하고, 현재 페이지는 링크 없이 구분합니다.",
usageEn: "Link to parent pages and distinguish the current page without a link.",
},
"back-to-top": {
description: "스크롤한 콘텐츠의 시작으로 돌아갑니다.",
descriptionEn: "Return to the beginning of scrolled content.",
usage: "위쪽 화살표로 이동 방향을 알리고 스크롤할 때 접근하기 쉽게 배치합니다.",
usageEn: "Use an upward arrow and keep the control accessible while scrolling.",
},
"classic-tabs": {
description: "같은 맥락의 화면을 탭으로 전환합니다.",
descriptionEn: "Switch between related views using tabs.",
usage: "현재 선택된 탭을 하단 막대로 구분합니다.",
usageEn: "Mark the selected tab with a bottom indicator.",
},
button: {
description: "명령을 실행하거나 다음 단계로 이동하는 기본 액션입니다.",
descriptionEn:
"A basic action for running commands or moving to the next step.",
usage:
"주요 액션은 기본 버튼, 보조 액션은 Outline이나 Secondary로 구분합니다. 실행할 수 없는 액션은 비활성 상태로 표시합니다.",
usageEn:
"Use default for primary actions and Outline or Secondary for supporting actions. Disable unavailable actions.",
},
input: {
description: "사용자가 텍스트를 입력하고 수정하는 필드입니다.",
descriptionEn: "A field for entering and editing text.",
usage:
"항상 명확한 라벨을 제공하고, 입력 형식과 오류를 설명합니다. 플레이스홀더는 라벨을 대신하지 않습니다.",
usageEn:
"Provide a clear label and explain input formats and errors. A placeholder does not replace a label.",
},
select: {
description: "미리 정의된 선택지 중 하나를 선택합니다.",
descriptionEn: "Choose one option from a predefined list.",
usage:
"선택지가 제한되어 있을 때 사용합니다. 현재 선택을 표시하고, 키보드로 열기·이동·선택할 수 있게 합니다.",
usageEn:
"Use for a finite list of choices. Show the selection and support keyboard navigation.",
},
checkbox: {
description: "여러 선택지에서 원하는 항목을 각각 선택하거나 해제합니다.",
descriptionEn: "Select or deselect independent options from a list.",
usage: "여러 항목을 동시에 선택할 수 있을 때 사용합니다. 각 항목에 명확한 라벨을 제공하고, 선택 여부를 체크 표시로 구분합니다.",
usageEn: "Use when multiple options can be selected. Provide a clear label for each option and distinguish selection with a checkmark.",
},
"radio-group": {
description: "서로 배타적인 선택지 중 하나를 선택합니다.",
descriptionEn: "Choose one option from a mutually exclusive set.",
usage: "한 번에 하나만 선택해야 할 때 사용합니다. 관련 선택지를 묶고, 모든 선택지를 보여주어 바로 비교할 수 있게 합니다.",
usageEn: "Use when exactly one option should be selected. Group related options and show all choices for immediate comparison.",
},
slider: {
description: "연속적인 범위에서 값을 조절합니다.",
descriptionEn: "Adjust a value within a continuous range.",
usage:
"볼륨이나 밝기처럼 상대적인 값 조절에 사용합니다. 현재 값을 표시하고, 정밀한 입력이 필요하면 입력 필드를 함께 제공합니다.",
usageEn:
"Use for relative values like volume. Show the current value and provide an input when precision is needed.",
},
switch: {
description: "설정을 켜거나 끄는 두 가지 상태의 입력 요소입니다.",
descriptionEn: "A binary control for turning a setting on or off.",
usage:
"알림처럼 변경이 즉시 적용되는 설정에 사용합니다. 라벨은 설정 이름으로 유지하고, 켜짐과 꺼짐은 위치와 색으로 함께 구분합니다.",
usageEn:
"Use for settings that take effect immediately, such as notifications. Keep the setting name as the label and distinguish on and off through both position and color.",
},
card: {
description: "관련 정보와 액션을 하나의 주제로 묶습니다.",
descriptionEn: "Group related information and actions around one topic.",
usage:
"제목·설명·내용·액션의 위계를 유지합니다. 서로 다른 주제는 나누고 카드 안에 카드를 중첩하지 않습니다.",
usageEn:
"Maintain a clear hierarchy of title, description, content, and actions. Avoid nesting cards.",
},
"bottom-sheet": {
description: "화면 아래에서 보조 작업이나 선택지를 엽니다.",
descriptionEn:
"Open supporting tasks or choices from the bottom of the screen.",
usage:
"짧은 선택이나 보조 작업에 사용합니다. 제목과 닫기 수단을 제공하고, 긴 내용은 시트 안에서 스크롤합니다.",
usageEn:
"Use for short choices or supporting tasks. Provide a title, dismissal controls, and scrolling for long content.",
},
tabs: {
description: "같은 맥락의 콘텐츠 패널을 전환합니다.",
descriptionEn: "Switch between related content panels.",
usage:
"세그먼트 이름을 짧고 명확하게 유지합니다. 독립된 문서 페이지로 이동하는 용도에는 링크를 사용합니다.",
usageEn:
"Keep tab labels concise. Use links to navigate to independent document pages.",
},
pagination: {
description: "여러 페이지로 나뉜 결과를 탐색합니다.",
descriptionEn: "Navigate through results split across pages.",
usage:
"현재 페이지와 이전·다음 이동을 명확하게 표시합니다. 첫 페이지와 마지막 페이지에서는 해당 이동을 비활성화합니다.",
usageEn:
"Show the current page and previous/next controls. Disable controls at the first and last pages.",
},
"dot-pagination": {
description: "이미지 캐러셀에서 현재 위치와 전체 이미지 수를 점으로 표시합니다.",
descriptionEn: "Show the current position and image count in an image carousel using dots.",
usage: "이미지가 적을 때 사용합니다. 선택된 점을 명확하게 구분하고, 점을 누르면 해당 이미지로 이동합니다.",
usageEn: "Use for a small set of images. Clearly distinguish the active dot and let each dot navigate to its image.",
},
filter: {
description: "조건을 선택해 표시할 결과를 좁힙니다.",
descriptionEn: "Narrow visible results by selecting conditions.",
usage:
"선택 상태와 결과 수를 함께 표시합니다. 조건을 해제하거나 전체 결과로 돌아갈 수 있어야 합니다.",
usageEn:
"Show selected conditions and the result count. Allow conditions to be cleared.",
},
badge: {
description: "상태나 짧은 보조 정보를 표시합니다.",
descriptionEn: "Display a status or short supporting information.",
usage:
"상태 이름은 짧게 작성합니다. 색상만으로 의미를 전달하지 말고 텍스트를 함께 사용합니다.",
usageEn:
"Keep status labels concise. Pair color with text rather than relying on color alone.",
},
tag: {
description: "콘텐츠의 주제나 적용된 조건을 표시합니다.",
descriptionEn: "Label content topics or applied conditions.",
usage:
"뱃지는 상태, 태그는 분류나 조건에 사용합니다. 제거할 수 있는 태그에는 이름이 있는 삭제 버튼을 제공합니다.",
usageEn:
"Use badges for status and tags for categories or conditions. Give removable tags a named removal button.",
},
avatar: {
description: "사람이나 팀을 작은 시각 요소로 식별합니다.",
descriptionEn: "Identify a person or team with a compact visual.",
usage:
"이미지를 사용할 수 없을 때 이니셜을 표시합니다. 이름을 함께 제공해 이미지에만 의존하지 않습니다.",
usageEn:
"Show initials when an image is unavailable. Provide a name alongside the avatar.",
},
progress: {
description: "시스템 응답을 기다리는 동안 로딩 상태를 표시합니다.",
descriptionEn: "Show a loading state while waiting for the system to respond.",
usage:
"진행률을 알 수 없는 대기 상태에는 반복 애니메이션을 사용합니다. 응답이 도착하면 로딩 표시를 제거합니다.",
usageEn:
"Use a looping animation when progress is unknown. Remove the loading indicator when the response arrives.",
},
dialog: {
description: "현재 페이지에서 짧은 편집 작업을 처리합니다.",
descriptionEn: "Handle a focused editing task on the current page.",
usage:
"저장은 변경을 반영하고, 취소나 Escape는 변경 없이 닫습니다. 명확한 제목과 설명을 제공합니다.",
usageEn:
"Save applies changes. Cancel and Escape close without changes. Provide a clear title and description.",
},
"alert-dialog": {
description: "현재 작업을 막지 않고 화면 한쪽에서 사용자의 판단을 요청합니다.",
descriptionEn: "Ask for a decision alongside the current task without blocking it.",
usage:
"쿠키 권한처럼 즉시 응답하지 않아도 되는 선택에 사용합니다. 배경 작업을 유지하고 선택지는 명확하게 구분합니다.",
usageEn:
"Use for non-urgent decisions such as cookie preferences. Keep background tasks available and distinguish choices clearly.",
},
}
export const foundationDocs = [
{
category: "elements",
slug: "writing",
title: "글",
titleEn: "Writing",
group: "Elements",
description:
"사용자가 상태와 다음 행동을 이해할 수 있는 문장을 작성합니다.",
descriptionEn:
"Write content that helps people understand the current state and next action.",
rules: [
"버튼은 실행할 행동을 구체적으로 설명합니다.",
"오류는 문제와 해결 방법을 함께 안내합니다.",
"같은 기능은 같은 단어로 표현합니다.",
],
rulesEn: [
"Button labels describe the action.",
"Errors explain the problem and recovery.",
"Use consistent words for the same function.",
],
},
{
category: "composition",
slug: "layers",
title: "레이어",
titleEn: "Layers",
group: "Composition",
description: "배경, 콘텐츠, 오버레이의 깊이를 구분합니다.",
descriptionEn:
"Distinguish the depth of backgrounds, content, and overlays.",
rules: [
"기본 콘텐츠보다 팝오버와 다이얼로그를 위에 배치합니다.",
"오버레이는 배경 작업과 현재 작업을 구분합니다.",
"레이어가 닫히면 원래 컨트롤로 포커스를 돌려줍니다.",
],
rulesEn: [
"Place popovers and dialogs above base content.",
"Use overlays to separate the current task.",
"Restore focus when a layer closes.",
],
},
{
category: "composition",
slug: "organization",
title: "조직화",
titleEn: "Organization",
group: "Composition",
description: "관련 정보를 묶고 중요도에 따라 순서를 정합니다.",
descriptionEn: "Group related information and order it by importance.",
rules: [
"관련된 항목은 가까이 배치합니다.",
"제목, 설명, 액션의 위계를 유지합니다.",
"반복되는 그룹은 동일한 구조로 구성합니다.",
],
rulesEn: [
"Keep related items close together.",
"Maintain a hierarchy of title, description, and actions.",
"Give repeated groups the same structure.",
],
},
{
category: "composition",
slug: "layout",
title: "레이아웃",
titleEn: "Layout",
group: "Composition",
description: "화면 크기에 맞춰 콘텐츠의 정렬과 간격을 조절합니다.",
descriptionEn: "Adapt content alignment and spacing to the screen size.",
rules: [
"간격은 4px 기준의 일관된 단계로 사용합니다.",
"좁은 화면에서는 읽는 순서를 유지하며 세로로 배치합니다.",
"긴 텍스트가 컨트롤을 밀어내거나 겹치지 않게 합니다.",
],
rulesEn: [
"Use consistent spacing based on a 4px grid.",
"Stack content on narrow screens without changing reading order.",
"Prevent long text from overlapping controls.",
],
},
{
category: "interaction",
slug: "information-architecture",
title: "정보 구조",
titleEn: "Information architecture",
group: "Interaction",
description:
"사용자가 원하는 내용을 찾을 수 있도록 탐색 경로를 설계합니다.",
descriptionEn:
"Design navigation paths that help people find what they need.",
rules: [
"왼쪽 메뉴는 독립된 페이지 이동에 사용합니다.",
"오른쪽 목차는 현재 페이지 안의 섹션 이동에 사용합니다.",
"현재 위치와 선택 상태를 명확히 표시합니다.",
],
rulesEn: [
"Use left navigation for independent pages.",
"Use the right table of contents for sections on the current page.",
"Show the current location and selection.",
],
},
{
category: "interaction",
slug: "input-output",
title: "입력과 출력",
titleEn: "Input and output",
group: "Interaction",
description: "사용자의 입력에 명확한 상태와 결과로 응답합니다.",
descriptionEn: "Respond to user input with clear states and outcomes.",
rules: [
"입력 필드에 라벨과 필요한 형식을 제공합니다.",
"저장, 오류, 완료 상태를 즉시 안내합니다.",
"키보드와 보조 기술로 같은 작업을 수행할 수 있게 합니다.",
],
rulesEn: [
"Provide labels and input formats.",
"Communicate saving, errors, and completion promptly.",
"Support the same tasks with keyboard and assistive technology.",
],
},
]
export const catalogTranslations = Object.fromEntries([
...Object.entries(extendedCatalogTranslations),
...Object.values(componentDetails).flatMap((item) => [
[item.description, item.descriptionEn],
[item.usage, item.usageEn],
]),
...foundationDocs.flatMap((item) => [
[item.title, item.titleEn],
[item.description, item.descriptionEn],
...item.rules.map((rule, index) => [rule, item.rulesEn[index]]),
]),
])
```