Stacks | CoUI
LogoCoUI

Stacks

자식을 z 축으로 겹쳐 쌓는 레이아웃 — `Stacks` + `Position` (Flutter `Stack` / `Positioned` 매핑)

Stacks#

z 축으로 자식을 겹쳐 그리는 레이아웃 primitive 입니다. 위젯 클래스 이름은 양 플랫폼 모두 Stacks, 절대 좌표로 배치되는 자식은 Position 입니다. StackLayout 은 이 페이지를 가리키는 이름(경로 /components/stack-layout)일 뿐이고, 그런 클래스는 어느 플랫폼에도 없습니다.

Flutter 의 Stack 과 시맨틱이 1:1 동일하며 (fit / alignment / clipBehavior / textDirection), 자식 중 Position 인 항목은 top / right / bottom / left / width / height 로 절대 좌표 배치됩니다 — Flutter Positioned 와 동등.

카드 덱 같은 누적 겹침 효과는 자식들을 모두 Position 으로 감싸 top: i * offset 형태로 누적 좌표를 직접 지정해서 만듭니다.

Live Preview#

Web
Layer 1
Layer 2
Layer 3
Flutter
Loading Flutter...
class StackLayoutDefaultExample extends StatelessComponent {
  const StackLayoutDefaultExample({super.key});

  static const _kLabels = ['Layer 1', 'Layer 2', 'Layer 3'];
  static const _kPerLayerOffset = CoreSpace.space8;

  @override
  Component build(BuildContext context) {
    final cs = context.theme.colorScheme;
    final layerClasses =
        'w-${CoreSpace.scale.space160} h-${CoreSpace.scale.space80} '
        'flex items-center justify-center '
        'bg-${cs.surfaceContainer} border border-${cs.outline} '
        'rounded-${CoreRadius.scale.radius16} '
        'text-${CoreTextStyles.bodyMedium.name} text-${cs.onSurface}';
    final totalHeight =
        CoreSpace.space80 + (_kLabels.length - 1) * _kPerLayerOffset;
    return div(
      [
        Stacks(
          children: [
            for (var i = 0; i < _kLabels.length; i++)
              Position(
                top: i * _kPerLayerOffset,
                child: div(
                  [Text(_kLabels[i])],
                  classes: layerClasses,
                ),
              ),
          ],
        ),
      ],
      styles: Styles(
        raw: {
          'width': '${CoreSpace.space160 / 16}rem',
          'height': '${totalHeight / 16}rem',
        },
      ),
    );
  }
}
class StackLayoutDefaultExample extends StatelessWidget {
  const StackLayoutDefaultExample({super.key});

  static const _kLabels = ['Layer 1', 'Layer 2', 'Layer 3'];
  static const _kPerLayerOffset = CoreSpace.space8;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final totalHeight =
        CoreSpace.space80 + (_kLabels.length - 1) * _kPerLayerOffset;
    return SizedBox(
      width: CoreSpace.space160,
      height: totalHeight,
      child: Stacks(
        children: [
          for (var i = 0; i < _kLabels.length; i++)
            Position(
              top: i * _kPerLayerOffset,
              child: Container(
                width: CoreSpace.space160,
                height: CoreSpace.space80,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: theme.colorScheme.surfaceContainer.toValue(),
                  borderRadius: BorderRadius.circular(CoreRadius.radius16),
                  border: Border.all(color: theme.colorScheme.outline.toValue()),
                ),
                child: Text(
                  _kLabels[i],
                  style: theme.typography.bodyMedium.toValue(theme: theme).copyWith(
                    color: theme.colorScheme.onSurface.toValue(),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 자식을 z 축으로 겹쳐야 할 때 (배지, 오버레이, 카드 덱)
  • Flutter Stack / Web position: absolute양쪽 동일한 시맨틱 이 필요할 때
  • Position 으로 자식의 절대 좌표를 명시하고 싶을 때

대신 다른 컴포넌트를 사용하세요:

  • Group: 같은 절대 배치를 자식 위젯 대신 데이터 목록 으로 주고 싶을 때 — 자식마다 Position 으로 감싸지 않고 CoreGroupPositionedData (top / left / right / bottom / width / height) 로 좌표를 넘깁니다. alignment / fit / clipBehavior 노브는 없고 semanticLabel 랜드마크가 있습니다
  • Resizable: 분할 가능한 좌우/상하 영역이 필요할 때

기본 사용법 (Basic Usage)#

// 카드 덱 — 자식들을 모두 Position 으로 감싸 누적 좌표 지정
Stacks(
  children: [
    for (var i = 0; i < labels.length; i++)
      Position(
        top: i * CoreSpace.space8,
        child: Card(child: Text(labels[i])),
      ),
  ],
)

// 일반 z 스택 — non-positioned 자식 + 우상단 배지
Stacks(
  children: [
    background,
    Position(
      top: CoreSpace.space8,
      right: CoreSpace.space8,
      child: badge,
    ),
  ],
)

Web 에서도 동일한 생성자로 사용합니다 — children: List<Component> 만 다릅니다.

Props / Parameters#

Stacks#

이름타입 (Flutter / Web)기본값설명
children List<Widget> / List<Component> required 자식 목록. 마지막 자식이 최상단
alignment CoreAlignment CoreAlignment.topStart non-positioned 자식의 정렬
fit CoreStackFit CoreStackFit.loose 부모 constraint 전달 정책 (loose / expand / passthrough)
clipBehavior CoreClipBehavior CoreClipBehavior.hardEdge overflow 클립 정책 ( none / hardEdge / antiAlias / antiAliasWithSaveLayer )
textDirection CoreTextDirection? null start/end 정렬을 ltr/rtl 로 해석할 방향

per-instance style 슬롯은 없습니다 — 세 축(alignment / fit / clipBehavior)이 곧 이 컴포넌트의 chrome 전부이고 위젯 파라미터로 직접 받습니다. Web 은 추가로 id / classes / css / attributes 와 DOM 이벤트 슬롯을 루트 <div> 로 통과시킵니다.

Position#

이름타입 (Flutter / Web)기본값설명
child Widget? / Component? required (nullable 허용) 위치 지정할 자식
top / right / bottom / left double? null 스택 박스 가장자리에서의 거리 (logical px)
width / height double? null 자식의 명시 크기 (logical px)

Position.fill({ ... }) 편의 생성자는 top/right/bottom/left = 0 으로 자식이 스택 전체를 채우게 합니다 — Flutter Positionedfill 생성자에 대응합니다. 이 생성자는 width / height 를 받지 않습니다.

Theming#

CoreComponentTheme.stacks 의 단일 style 슬롯에서 기본값을 오버라이드합니다. textDirection 은 테마 슬롯에 없고 위젯 파라미터로만 받습니다:

CoreComponentTheme(
  stacks: CoreStacksTheme(
    style: CoreStacksStyle(
      alignment: CoreAlignment.topStart,
      fit: CoreStackFit.loose,
      clipBehavior: CoreClipBehavior.hardEdge,
    ),
  ),
)

사용 가이드라인 (Usage Guidelines)#

✅ Do#

절대 좌표가 필요한 자식만 Position 으로 감싸기

Stacks(
  alignment: CoreAlignment.center,
  children: [
    background,                                    // non-positioned → alignment 적용
    Position(top: CoreSpace.space8, right: CoreSpace.space8, child: badge),
  ],
)

절대 좌표가 필요한 자식만 Position 으로 감싸고 나머지는 alignment 에 맡기면, Flutter Stack/Positioned 와 동일한 시맨틱을 그대로 유지할 수 있습니다.


❌ Don't#

오버레이 아래 콘텐츠를 상호작용 가능한 채로 남겨두지 않기

// ❌ 화면을 덮는 오버레이를 올렸지만 아래 폼은 그대로 상호작용 가능
Stacks(
  children: [
    interactiveForm,
    Position.fill(child: modalOverlay),
  ],
)

Stacks 는 가려진 레이어에 ExcludeSemantics/inert 를 걸지 않습니다 — 시각적으로 완전히 덮인 interactiveForm 도 여전히 Tab 으로 도달되고 스크린 리더에 읽힙니다. 오버레이 아래 콘텐츠의 상호작용을 실제로 막는 것은 호출자의 몫입니다.

✅ Do#

전체를 채우는 자식은 Position.fill 사용

Position.fill(child: backdrop)

top/right/bottom/left 를 각각 0 으로 반복해서 채우는 대신 Position.fill 편의 생성자를 쓰면 스택 전체를 채우려는 의도가 코드에 그대로 드러납니다.


❌ Don't#

Position.fill 에 크기 지정 시도 금지

// ❌ Position.fill 은 width / height 를 받지 않음
Position.fill(width: 200, height: 100, child: backdrop)

Position.filltop/right/bottom/left = 0 만 고정하는 생성자라 width/height 파라미터가 없습니다 — 고정 크기가 필요하면 일반 Position(width:, height:, ...) 을 사용해야 합니다.

접근성 (Accessibility)#

Stacks 는 시맨틱을 전혀 만들지 않는 순수 레이아웃 래퍼입니다. 리더가 읽는 내용은 전부 자식이 스스로 내보낸 것이며, 스택 자신은 아무것도 보태지 않습니다.

역할 / 시맨틱#

  • Flutter: Stacks 는 프레임워크 Stack 을, Position 은 프레임워크 Positioned 를 그대로 반환합니다. Semantics 노드를 하나도 추가하지 않습니다.
  • Web: 루트는 평범한 <div> 이고, Position 이 아닌 자식은 래퍼 <div> 로 한 번 더 감쌉니다. role · aria-* · tabindex 를 붙이지 않습니다. 컴포넌트가 스스로 만드는 속성은 textDirection 에서 온 dir="ltr|rtl" 하나뿐이고, 나머지 attributes 는 호출자가 준 값이 그대로 통과합니다.

스택 영역에 역할(region · group 등)이나 이름이 필요하면 호출자가 직접 감싸서 붙여야 합니다.

키보드#

처리하는 키가 없습니다. 양쪽 모두 자체 키 핸들러를 두지 않으며, Web 이 상속하는 onKeyDown / onKeyUp 은 호출자 통과용일 뿐 컴포넌트가 바인딩하는 키는 없습니다.

포커스#

포커스를 받지 않습니다. FocusNodetabindex 도 없고, 포커스 트랩 · 복원 · 순서 재정의를 하지 않습니다. 자식은 각자 가지고 온 포커스 가능성을 그대로 유지합니다.

스크린 리더#

스택 자신은 읽히지 않습니다. Flutter 는 시맨틱 노드를 만들지 않아 자식만 자식 순서대로 읽히고, Web 은 일반 콘텐츠로 DOM 순서대로 읽힙니다. 리더가 따라가는 순서는 DOM 순서이지 시각적 z 순서나 alignment 가 아닙니다 — 마지막 자식이 화면에서 가장 위에 그려져도 읽히는 순서는 마지막입니다.

알려진 제약#

  • 겹친 자식은 전부 동시에 트리에 존재합니다. 가려진 레이어에 inertExcludeSemantics 를 걸지 않으므로, 시각적으로 완전히 덮여 보이지 않는 레이어도 여전히 읽히고 여전히 Tab 으로 도달합니다. 콘텐츠 위에 오버레이를 쌓는다면 가려진 서브트리를 죽이는 것은 전적으로 호출자 몫입니다.
  • 역할 · 레이블 · 그룹핑이 없어 스택은 리더의 구조 목록에 나타나지 않습니다.

이 컴포넌트가 직접 다루지 않는 축은 전역 접근성 축 에 있습니다.

레거시 vs 통일 비교 (Migration Notes)#

이전 버전의 Web CoUI 를 참조하는 코드에는 이 컴포넌트의 옛 이름이 Flutter 자체 위젯과 같았을 수 있습니다(겹침 대상: z축 레이어 위젯, 좌표 지정 위젯). Flutter 자체 위젯과 이름이 겹치면 프레임워크 심볼과 충돌하므로, Web 도 Flutter-first 원칙에 따라 Stacks/Position 으로 개명되었습니다.

항목구버전 이름통일 이름
API동일 named properties동일 named properties
이름 충돌Flutter/Dart 자체 위젯과 겹침겹치지 않음

마이그레이션: 옛 이름을 각각 Stacks/Position/Position.fill 로 바꾸면 됩니다 — 파라미터는 그대로입니다.

관련 컴포넌트#

  • Group — 절대 좌표 기반 자유 배치
  • Resizable — 분할 가능 영역