Tiro
Tiro Design
Components

Item

media, title, description, actions를 한 줄 문법으로 묶는 조합형 primitive.

Item은 Tiro의 설정, 멤버, 결제 요약, 네비게이션 row를 구성하는 조합형 primitive다. Dialog, Popover처럼 Radix가 상태와 포커스 관리까지 책임지는 종류는 아니고, 의미 있는 슬롯을 가진 layout primitive에 가깝다.

즉 구현은 대부분 div / li / button / a 조합으로 끝나는 것이 정상이다. 중요한 건 내부가 단순 div냐가 아니라, ItemMedia · ItemContent · ItemActions라는 안정된 API와 정렬 규칙을 가지느냐다.

상호작용 root가 필요하면 Itembutton이나 a로 렌더할 수 있어야 한다. 구현 시에는 asChild 또는 slot 패턴을 권장한다.

Mobile

Interactive Item은 모바일에서 행 전체가 터치 타겟이 되도록 구성한다.

  • 최소 행 높이 ≥44pt (iOS HIG). dense list라도 44pt 미만으로 내리지 말 것
  • 행 전체를 Pressable 또는 TouchableOpacity로 감싸 media·content·actions 어디를 탭해도 root 액션이 트리거되게 함
  • ItemActions 내부의 별도 액션 버튼(예: trailing icon-only)은 행 전체 hit area에서 분리된 별도 hit area를 가져야 함 (e.g. stopPropagation). 각 액션 버튼도 ≥44pt 확보 — Button → Mobile icon-only 가이드 참조
  • ItemMedia의 아바타/아이콘은 비인터랙티브하더라도 시각적으로 충돌하지 않게 gap-3 이상 확보
  • Swipe action(좌→우 삭제, 우→좌 아카이브)은 행 root와 독립된 gesture로 별도 라이브러리 사용 시 명시 (예: react-native-gesture-handlerSwipeable)

Preview

각 Item은 standalone outline으로 보여준다. 슬롯 조합만 다를 뿐, 모두 동일한 media / content / actions 문법을 따른다. 여러 Item을 그룹으로 묶을 때는 List가 surface를 책임지고 개별 outline은 사라진다.

Anatomy

Item
├─ ItemMedia          (optional)
├─ ItemContent        (required)
│  ├─ ItemTitle       (required)
│  └─ ItemDescription (optional)
└─ ItemActions        (optional)

필요하면 아래 두 슬롯을 추가할 수 있다.

ItemMeta             // 우측 보조 텍스트
ItemFooter           // row 아래 확장 영역

Slots

SlotRequiredPurposeAllowed content
ItemMediaoptional좌측 식별자icon, avatar, color swatch, thumbnail
ItemContentrequired주 정보 블록title + description
ItemTitlerequired행의 핵심 라벨plain text
ItemDescriptionoptional보조 설명1–2 lines text
ItemMetaoptional날짜·시간·길이 등 스캔용 메타데이터text, small icon, compact inline group
ItemActionsoptional우측 액션/컨트롤button, select trigger, switch, chevron, badge, TextInput (outline sm)

Slot rules

  • ItemContent는 항상 flex-1
  • ItemTitlelabel1Medium 또는 body1Regular 계열에서 시작
  • ItemDescriptionbody2Regular text-stone-500
  • 날짜·시간·길이처럼 반복적으로 훑는 ItemMetalabel2Regular text-stone-300을 기본으로 한다. 아이콘도 같은 톤을 상속하거나 text-stone-300에 맞춘다
  • ItemActions는 hugging width. 우측 끝선 정렬
  • ItemMediaItemActions는 모두 shrink하지 않는다

Media Variants

VariantSizeUse
icon40×40settings, actions, status rows — List/설정 화면 전용
avatar40×40member, assignee, participant — List/설정 화면 전용
swatch12×12color legend, calendar color, status dot
thumbnail48×48 to 56×56file, image, template preview

icon media는 기본적으로 stone-50 surface 위에 놓는다. 브랜드 brown이나 feedback 컬러는 아이콘 자체 의미가 있을 때만 제한적으로 사용한다.

**Overlay 컨텍스트 (Popover, Dialog, Command Palette, 검색 결과)**에서는 icon (40×40) variant를 사용하지 않는다. 컨테이너 없이 14–16px 아이콘을 직접 배치하거나, 최대 h-5 w-5 (20×20) 컨테이너를 사용한다.

Root Semantics

Item은 단일 HTML element로 닫히는 root를 가진다. 문맥에 따라 root element를 바꾼다.

CaseRecommended root
읽기 전용 summary rowdiv
리스트 안의 구조적 항목li
row 전체 클릭 내비게이션button or a
menu-like action rowbutton

구현 시 asChild 또는 slot 패턴을 권장한다. Item 자체가 복잡한 behavior primitive는 아니므로 Radix root가 필수는 아니다.

Variants

  • default — 배경 없음. List 안의 기본 row
  • outline — thin border + white surface. standalone item
  • mutedstone-50 기반 보조 row

Item이 standalone으로 쓰일 때만 outline을 직접 사용한다. 여러 개를 묶을 때는 보통 List가 surface를 책임진다.

Sizes

SizeMin height (web)Min height (mobile)Use
sm48px48pxdense utility row
md56px56pxdefault
lg64px64pxmedia 포함, 설명 2줄, 더 여유 있는 settings row

모든 사이즈가 모바일 최소 터치 타겟(iOS HIG 44pt)을 충족. dense list라도 44pt 미만으로 내리지 말 것 — 시각적 압축이 필요하면 sm까지가 한계.

Tiro는 flat-first 인터페이스라 과도한 세로 여백을 쓰지 않는다. row는 조용하고 압축적이어야 한다.

Overlay 컨텍스트 사이즈 제한 (web only): Popover · Dialog · Command Palette · 검색 결과 목록 안에서 Item이 사용될 경우, 단일 라인 선택 row는 h-7 (28px)을 기본으로 한다. 아이콘 슬롯이나 보조 설명이 필요한 row도 h-8 (32px) ~ h-9 (36px) 을 상한으로 한다. md (56px) 이상은 오버레이 내부에서 사용하지 않는다. 모바일은 적용 불가 — overlay 자체가 BottomSheet로 변환되며 row는 ≥44pt 유지.

Token Mapping

TokenValue
horizontal paddingpx-5
vertical paddingpy-2.5
content gapgap-3
titlelabel1Medium, text-stone-700
descriptionbody2Regular, text-stone-500
metadatalabel2Regular, text-stone-300
media bgstone-50
outline borderstone-100
outline radiusrounded-xl
hover bgstone-50

Usage

<Item variant="outline">
  <ItemMedia variant="icon">
    <ShieldAlertIcon />
  </ItemMedia>
  <ItemContent>
    <ItemTitle>Security Alert</ItemTitle>
    <ItemDescription>New login detected from unknown device.</ItemDescription>
  </ItemContent>
  <ItemActions>
    <Button size="sm" variant="outline">
      Review
    </Button>
  </ItemActions>
</Item>
<Item asChild>
  <button type="button">
    <ItemContent>
      <ItemTitle>Workspace name</ItemTitle>
      <ItemDescription>기본 워크스페이스 이름을 수정합니다.</ItemDescription>
    </ItemContent>
    <ItemActions>
      <ChevronRight />
    </ItemActions>
  </button>
</Item>

TextField in ItemActions

ItemActionsTextInput을 배치해 inline 폼 필드를 만들 수 있다. 이 패턴은 설정 화면의 "이름 편집", "직책 입력" 같이 제목과 입력이 한 row에 나란히 있어야 할 때 사용한다.

<Item>
  <ItemContent>
    <ItemTitle>닉네임</ItemTitle>
  </ItemContent>
  <ItemActions>
    <TextInput size="sm" className="w-[200px]" value={nickname} onChange={...} />
  </ItemActions>
</Item>
  • size="sm" (h-8) 사용 — row 높이와 균형을 맞추기 위해
  • 고정 너비(w-[160px]~w-[240px]) 지정 — actions 영역을 예측 가능하게 유지
  • variant="outline" (기본값) — border가 항상 보여야 field임을 식별할 수 있음

Interactive Row (Navigation / Selectable)

사이드바 폴더 항목, 노트 목록 행처럼 클릭·선택·드래그 상태가 있는 row에 적용하는 패턴이다. 설정 화면의 정적 summary row와 구분된다.

상태 정의

Statebgtext비고
defaulttransparentstone-700배경 없음
hoverstone-50stone-700hover:bg-stone-50
selectedstone-50brown-700bg-stone-50 text-brown-700
draggingtransparentrow 자체 opacity-40, 드래그 고스트 별도
drop-targetstone-50bg-stone-50 (dragging과 동시에 적용 안 함)
disabledtransparentstone-300opacity-50 pointer-events-none
  • focus-visible: ring-2 ring-offset-2 ring-brown-800 — hover와 시각적으로 구분되어야 함
  • selectedhover가 겹칠 때는 selected 스타일이 우선한다 (배경 동일, 텍스트 색상만 유지)

인라인 액션 (group-hover 노출)

row 오른쪽에 붙는 ⋯, 삭제, 잠금 같은 액션은 기본 opacity-0, hover 시 opacity-100 으로 제어한다.

// 부모 row에 group 클래스 추가
<div className="group flex items-center ...">
  {/* 항상 보이는 content */}
  <span>{label}</span>

  {/* hover에서만 보이는 액션 버튼 */}
  <button
    className="opacity-0 group-hover:opacity-100 transition-opacity
                p-1 rounded-lg hover:bg-stone-100"
  >
    <MoreHorizontal size={14} strokeWidth={1.75} />
  </button>
</div>
  • 액션 버튼 크기: xs (28×28) — row context에서는 한 단계 낮은 사이즈 규칙 (Button 문서 참조)
  • 액션 버튼 자체 hover: hover:bg-stone-100 (row hover stone-50보다 한 단계 진하게)
  • Popover가 열려 있는 동안은 opacity-100을 강제로 유지해야 함 (isPopoverOpen && "opacity-100")
  • 아이콘은 Lucide 전용 (MoreHorizontal, LockKeyhole, Users 등) — react-icons 사용 금지

계층 들여쓰기

트리형 계층 구조(폴더 트리)에서는 padding-left를 동적으로 계산한다.

// depth × 16px + base offset
style={{ paddingLeft: `${depth * 16 + 6}px` }}
  • 들여쓰기는 style prop으로 계산 — Tailwind arbitrary value 사용 금지
  • depth 0의 base left padding은 px-1.5 (6px) 유지

Expand/Collapse 토글

자식이 있는 행에만 노출. 크기: 12×12 (w-3 h-3).

{
  hasChildren ? (
    <button
      onClick={(e) => {
        e.stopPropagation();
        onToggleExpand();
      }}
      className="flex h-3 w-3 items-center justify-center rounded transition-colors
                hover:bg-stone-100 flex-shrink-0"
    >
      {isExpanded ? (
        <ChevronDown size={12} strokeWidth={1.75} className="text-stone-500" />
      ) : (
        <ChevronRight size={12} strokeWidth={1.75} className="text-stone-500" />
      )}
    </button>
  ) : (
    <div className="w-3 flex-shrink-0" /> // 자식 없을 때 동일 너비 placeholder
  );
}

맥락 메뉴 Popover

row의 ⋯ 버튼을 눌러 열리는 인라인 컨텍스트 메뉴.

<PopoverContent className="w-fit p-1 bg-white border border-stone-200 rounded-lg shadow-sm">
  <button
    className="flex w-full items-center gap-2 rounded-md px-2 py-1.5
                      typography-caption1Regular text-stone-500 hover:bg-stone-100"
  >
    <Settings size={12} strokeWidth={1.75} />
    편집
  </button>
  {/* destructive action은 rose 계열 */}
  <button
    className="flex w-full items-center gap-2 rounded-md px-2 py-1.5
                      typography-caption1Regular text-rose-600 hover:bg-rose-50"
  >
    <Trash2 size={12} strokeWidth={1.75} />
    삭제
  </button>
</PopoverContent>
  • 메뉴 아이템 아이콘: 12px (size={12})
  • 일반 액션: text-stone-500 hover:bg-stone-100
  • destructive 액션: text-rose-600 hover:bg-rose-50hover:bg-stone-50 사용 금지 (Button 문서 Destructive Ghost 규칙과 동일)
  • shadow: shadow-sm (Elevation 2 — Subtle, popover/dropdown 전용)

전체 코드 예시

<div
  className={cn(
    "group flex items-center rounded-lg p-1.5 typography-label2Regular",
    "transition-colors duration-150 select-none",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brown-800",
    !isDragging && "hover:bg-stone-50",
    isSelected && !isDragging && "bg-stone-50",
    isDragging && "opacity-40",
    isDropTarget && !isDragging && "bg-stone-50",
  )}
  style={{ paddingLeft: `${depth * 16 + 6}px` }}
>
  {/* expand toggle */}
  {/* icon + label (draggable area) */}
  {/* group-hover actions */}
</div>

Mobile selectable row — Recording folder sheet

tiro-app main에서 녹음 메인 시트 상단의 FolderSelectionChip을 누르면 열리는 폴더 변경 화면이다. RecordingBottomSheetHeader의 상단 chip이 folderSheetRef.current?.present()를 호출하고, TrueSheet 안에서 RecordingFolderTabFolderSelectionContent를 렌더한다.

Preview에서는 상위 폴더를 눌러 하위 폴더가 펼쳐진 상태를 함께 보여준다. row는 FolderSelectionContent.renderFolderTreeItem 기준이다.

PartSource value
triggerFolderSelectionChip: px-[6px] py-[4px] rounded-[6px] bg-[#F5F5F4], Folder size={14}, 첫 폴더명 + + N
sheetTrueSheet, backgroundColor="#ffffff", detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable
headerBottomSheetTitleHeader, title note.folder.addToFolder, rightText common.confirm, disabled when no changes
contentFolderSelectionContent, ScrollView className="px-5 pt-5 pb-6 gap-[10px]"
section labeltext-[#3F3F46] text-[14px] tracking-[-0.5px] font-['Pretendard-SemiBold'] leading-6
section cardMy: rounded-2xl bg-[#FAFAFA] p-[10px] gap-1; Team: rounded-[20px] bg-[#FAFAFA] p-[10px] gap-1
row wrapperw-full flex-row items-center rounded-[14px]; selected wrapper bg-white
expand controlTouchableOpacity w-6 h-6 mr-1, ChevronDown/Right size={20} color="#A1A1AA", hitSlop={8}
row press targetflex-1 flex-row items-center justify-between py-[10px] pr-[10px], activeOpacity={0.7}
folder iconpersonal Folder size={20} color={thread.color}; team TeamFolderIcon size={20}
titletext-[16px] leading-6 tracking-[-0.7px]; selected #18181B SemiBold, default #2D2B2B Regular
trailingselected Check size={20} color="#18181B"; unselected Check size={20} color="#E4E4E7"; locked LockKeyhole size={16} color="#A1A1AA"; pending ActivityIndicator
StateRowTitleTrailing
defaulttransparent#2D2B2B, Pretendard RegularCheck #E4E4E7
selectedwrapper bg-white#18181B, Pretendard SemiBoldCheck #18181B
disabledpress target disableddefault or selectedLockKeyhole #A1A1AA when not selected
pendingpress target disabledcurrent title stateActivityIndicator #A1A1AA

Do / Don't

  • Do: row의 정보 구조를 media / content / actions로 안정적으로 반복한다
  • Do: 버튼, 셀렉트, 스위치 등은 각 컴포넌트 문서에서 정의한 크기와 타입을 그대로 가져다 쓴다
  • Do: root element를 문맥에 맞게 바꾼다
  • Do: ItemActionsTextInput을 쓸 때는 size="sm"과 고정 너비를 함께 지정한다
  • Do: interactive row의 인라인 액션 아이콘은 Lucide만 사용한다
  • Do: destructive 액션 hover는 반드시 hover:bg-rose-50를 사용한다
  • Don't: row마다 임의의 padding과 radius를 새로 만들지 않는다
  • Don't: raw browser button/input 스타일을 그대로 쓰지 않는다
  • Don't: 한 item 안에 독립 인터랙션을 3개 이상 병렬 배치하지 않는다
  • Don't: Popover · Dialog · 검색 결과 등 오버레이 컨텍스트에서 icon (40×40) 미디어 variant를 사용하지 않는다 — 오버레이 내부에서는 아이콘 컨테이너 없이 14–16px 아이콘 직접 배치, 또는 최대 h-5 w-5 컨테이너 사용
  • Don't: 날짜 · 시간처럼 짧은 메타데이터를 오버레이 컨텍스트에서 ItemDescription (두 번째 줄)로 내리지 않는다 — ItemMeta로 같은 행 우측에 인라인 배치
  • Don't: bordered TextInput을 2개 이상 같은 Item row 안에 나란히 배치하지 않는다. List surface 카드(border)가 이미 있는 상황에서 input border가 2개 이상 가로로 나란히 놓이면 "박스 안에 박스 여러 개" 노이즈가 생긴다. 입력 필드가 2개 이상인 추가 form은 List 외부에 별도 블록으로 분리한다.
  • Don't: TextareaItemActions에 넣지 않는다 — 여러 줄 입력은 Item 슬롯 구조에 맞지 않으므로 List 안의 자유 블록(px-5 py-3 wrapper)으로 배치한다
  • Don't: interactive row에서 text-gray-*, bg-gray-* (cool gray 계열) 사용 금지 — 반드시 stone-* 계열
  • Don't: 인라인 HEX 색상 사용 금지 — TDS 토큰 클래스 또는 Tailwind 피드백 스케일 사용
  • Don't: 들여쓰기에 Tailwind arbitrary value (pl-[22px]) 사용 금지 — style prop으로 동적 계산

On this page