Stack

Flex layout as props, on a closed spacing scale.

First
Second
Third
import * as stylex from "@stylexjs/stylex"
import { VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    backgroundColor: `color-mix(in oklab, ${color.muted} 40%, transparent)`,
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
})

export default function StackDemo() {
  return (
    <VStack gap={2} width="16rem">
      <div {...stylex.props(styles.card)}>First</div>
      <div {...stylex.props(styles.card)}>Second</div>
      <div {...stylex.props(styles.card)}>Third</div>
    </VStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer, if you have not already. Stack reads the space scale. See Installation.

Copy and paste the following code into your project.

components/ui/stack.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import type { CSSProperties, ElementType, ReactNode } from "react"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { space } from "@/theme/tokens.stylex"

export type SpacingStep = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12

const gapStyles = stylex.create({
  0: { columnGap: space.s0, rowGap: space.s0 },
  0.5: { columnGap: space.s0_5, rowGap: space.s0_5 },
  1: { columnGap: space.s1, rowGap: space.s1 },
  1.5: { columnGap: space.s1_5, rowGap: space.s1_5 },
  2: { columnGap: space.s2, rowGap: space.s2 },
  3: { columnGap: space.s3, rowGap: space.s3 },
  4: { columnGap: space.s4, rowGap: space.s4 },
  5: { columnGap: space.s5, rowGap: space.s5 },
  6: { columnGap: space.s6, rowGap: space.s6 },
  8: { columnGap: space.s8, rowGap: space.s8 },
  10: { columnGap: space.s10, rowGap: space.s10 },
  12: { columnGap: space.s12, rowGap: space.s12 },
})

const paddingInlineStartStyles = stylex.create({
  0: { paddingInlineStart: space.s0 },
  0.5: { paddingInlineStart: space.s0_5 },
  1: { paddingInlineStart: space.s1 },
  1.5: { paddingInlineStart: space.s1_5 },
  2: { paddingInlineStart: space.s2 },
  3: { paddingInlineStart: space.s3 },
  4: { paddingInlineStart: space.s4 },
  5: { paddingInlineStart: space.s5 },
  6: { paddingInlineStart: space.s6 },
  8: { paddingInlineStart: space.s8 },
  10: { paddingInlineStart: space.s10 },
  12: { paddingInlineStart: space.s12 },
})

const paddingInlineEndStyles = stylex.create({
  0: { paddingInlineEnd: space.s0 },
  0.5: { paddingInlineEnd: space.s0_5 },
  1: { paddingInlineEnd: space.s1 },
  1.5: { paddingInlineEnd: space.s1_5 },
  2: { paddingInlineEnd: space.s2 },
  3: { paddingInlineEnd: space.s3 },
  4: { paddingInlineEnd: space.s4 },
  5: { paddingInlineEnd: space.s5 },
  6: { paddingInlineEnd: space.s6 },
  8: { paddingInlineEnd: space.s8 },
  10: { paddingInlineEnd: space.s10 },
  12: { paddingInlineEnd: space.s12 },
})

const paddingBlockStartStyles = stylex.create({
  0: { paddingBlockStart: space.s0 },
  0.5: { paddingBlockStart: space.s0_5 },
  1: { paddingBlockStart: space.s1 },
  1.5: { paddingBlockStart: space.s1_5 },
  2: { paddingBlockStart: space.s2 },
  3: { paddingBlockStart: space.s3 },
  4: { paddingBlockStart: space.s4 },
  5: { paddingBlockStart: space.s5 },
  6: { paddingBlockStart: space.s6 },
  8: { paddingBlockStart: space.s8 },
  10: { paddingBlockStart: space.s10 },
  12: { paddingBlockStart: space.s12 },
})

const paddingBlockEndStyles = stylex.create({
  0: { paddingBlockEnd: space.s0 },
  0.5: { paddingBlockEnd: space.s0_5 },
  1: { paddingBlockEnd: space.s1 },
  1.5: { paddingBlockEnd: space.s1_5 },
  2: { paddingBlockEnd: space.s2 },
  3: { paddingBlockEnd: space.s3 },
  4: { paddingBlockEnd: space.s4 },
  5: { paddingBlockEnd: space.s5 },
  6: { paddingBlockEnd: space.s6 },
  8: { paddingBlockEnd: space.s8 },
  10: { paddingBlockEnd: space.s10 },
  12: { paddingBlockEnd: space.s12 },
})

const styles = stylex.create({
  base: { display: "flex", minWidth: 0 },
  horizontal: { flexDirection: "row" },
  vertical: { flexDirection: "column" },
  grow: { flexGrow: 1 },
  scrollable: { overflow: "auto", minHeight: 0 },
})

const wrapStyles = stylex.create({
  nowrap: { flexWrap: "nowrap" },
  wrap: { flexWrap: "wrap" },
  "wrap-reverse": { flexWrap: "wrap-reverse" },
})

const justifyStyles = stylex.create({
  start: { justifyContent: "flex-start" },
  center: { justifyContent: "center" },
  end: { justifyContent: "flex-end" },
  between: { justifyContent: "space-between" },
  around: { justifyContent: "space-around" },
  evenly: { justifyContent: "space-evenly" },
})

const alignStyles = stylex.create({
  start: { alignItems: "flex-start" },
  center: { alignItems: "center" },
  end: { alignItems: "flex-end" },
  stretch: { alignItems: "stretch" },
  baseline: { alignItems: "baseline" },
})

type SizeValue = number | string

const toSize = (value: SizeValue) => (typeof value === "number" ? `${value}px` : value)

type StackOwnProps = {
  direction?: "horizontal" | "vertical"
  justify?: keyof typeof justifyStyles
  align?: keyof typeof alignStyles
  gap?: SpacingStep
  padding?: SpacingStep
  paddingInline?: SpacingStep
  paddingInlineStart?: SpacingStep
  paddingInlineEnd?: SpacingStep
  paddingBlock?: SpacingStep
  paddingBlockStart?: SpacingStep
  paddingBlockEnd?: SpacingStep
  wrap?: keyof typeof wrapStyles
  grow?: boolean
  scrollable?: boolean
  width?: SizeValue
  height?: SizeValue
  maxWidth?: SizeValue
  minHeight?: SizeValue
  xstyle?: StyleXStyles
  style?: CSSProperties
  className?: string
  children?: ReactNode
}

type StackProps<T extends ElementType = "div"> = StackOwnProps &
  Omit<useRender.ComponentProps<T>, keyof StackOwnProps>

function Stack<T extends ElementType = "div">({
  render,
  ref,
  direction = "vertical",
  justify,
  align,
  gap,
  padding,
  paddingInline,
  paddingInlineStart,
  paddingInlineEnd,
  paddingBlock,
  paddingBlockStart,
  paddingBlockEnd,
  wrap,
  grow,
  scrollable,
  width,
  height,
  maxWidth,
  minHeight,
  xstyle,
  style,
  className,
  ...props
}: StackProps<T>) {
  const inlineStart = paddingInlineStart ?? paddingInline ?? padding
  const inlineEnd = paddingInlineEnd ?? paddingInline ?? padding
  const blockStart = paddingBlockStart ?? paddingBlock ?? padding
  const blockEnd = paddingBlockEnd ?? paddingBlock ?? padding

  const sx = stylex.props(
    styles.base,
    direction === "vertical" ? styles.vertical : styles.horizontal,
    justify && justifyStyles[justify],
    align && alignStyles[align],
    gap != null && gapStyles[gap],
    inlineStart != null && paddingInlineStartStyles[inlineStart],
    inlineEnd != null && paddingInlineEndStyles[inlineEnd],
    blockStart != null && paddingBlockStartStyles[blockStart],
    blockEnd != null && paddingBlockEndStyles[blockEnd],
    wrap != null && wrapStyles[wrap],
    grow && styles.grow,
    scrollable && styles.scrollable,
    xstyle,
  )

  const sizing: CSSProperties = {
    ...sx.style,
    ...(width != null && { width: toSize(width) }),
    ...(height != null && { height: toSize(height) }),
    ...(maxWidth != null && { maxWidth: toSize(maxWidth) }),
    ...(minHeight != null && { minHeight: toSize(minHeight) }),
    ...style,
  }

  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "stack",
      "data-orientation": direction,
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: Object.keys(sizing).length > 0 ? sizing : undefined,
    },
  })
}

function HStack<T extends ElementType = "div">(props: Omit<StackProps<T>, "direction">) {
  return <Stack<T> {...(props as StackProps<T>)} direction="horizontal" />
}

function VStack<T extends ElementType = "div">(props: Omit<StackProps<T>, "direction">) {
  return <Stack<T> {...(props as StackProps<T>)} direction="vertical" />
}

export { HStack, Stack, VStack }

Update the import paths to match your project setup.

Usage

import { HStack, Stack, VStack } from "@/components/ui/stack"
<HStack align="center" gap={2}>
  <Icon />
  <span>Label</span>
</HStack>

Use Stack when the styling is only the layout. Give an element with its own background, border or type a stylex.create() block, and put its layout there.

Direction

Stack runs vertically. HStack and VStack fix direction. Pass direction yourself only when the value is computed.

One
Two
Three
One
Two
import * as stylex from "@stylexjs/stylex"
import { HStack, VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    backgroundColor: `color-mix(in oklab, ${color.muted} 40%, transparent)`,
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
})

export default function StackDirection() {
  return (
    <VStack gap={4}>
      <HStack gap={2}>
        <div {...stylex.props(styles.card)}>One</div>
        <div {...stylex.props(styles.card)}>Two</div>
        <div {...stylex.props(styles.card)}>Three</div>
      </HStack>
      <VStack gap={2}>
        <div {...stylex.props(styles.card)}>One</div>
        <div {...stylex.props(styles.card)}>Two</div>
      </VStack>
    </VStack>
  )
}

Alignment

Use justify and align for justify-content and align-items, without the flex- prefixes.

between
center
end
end
import * as stylex from "@stylexjs/stylex"
import { HStack, VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  row: {
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    padding: space.s2,
  },
  tall: {
    borderRadius: radius.md,
    backgroundColor: color.muted,
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
  short: {
    borderRadius: radius.md,
    backgroundColor: `color-mix(in oklab, ${color.muted} 40%, transparent)`,
    paddingInline: space.s3,
    paddingBlock: space.s1,
  },
})

export default function StackAlignment() {
  return (
    <VStack gap={3} width="20rem">
      <HStack justify="between" align="center" gap={2} xstyle={styles.row}>
        <div {...stylex.props(styles.tall)}>between</div>
        <div {...stylex.props(styles.short)}>center</div>
      </HStack>
      <HStack justify="end" align="end" gap={2} xstyle={styles.row}>
        <div {...stylex.props(styles.tall)}>end</div>
        <div {...stylex.props(styles.short)}>end</div>
      </HStack>
    </VStack>
  )
}

Spacing

Every spacing prop takes a step off the scale. Each step is 0.25rem times its number, so gap={3} is 0.75rem. An off-scale value is a type error.

0  0.5  1  1.5  2  3  4  5  6  8  10  12

Padding props resolve most-specific-first, per edge. An edge prop beats its axis prop, which beats padding.

<VStack padding={4} paddingBlockEnd={8} />

Sizing

width, height, maxWidth and minHeight take a number, read as pixels, or a CSS length string. Stack writes them to the element's inline style.

<VStack gap={4} maxWidth="28rem" />

Scrolling

Set scrollable on a flex child that has to scroll. It sets overflow: auto and the min-height: 0 the child needs, or its automatic minimum size holds it at content height and the scrollbar never appears. Pair it with grow.

<VStack height="100svh">
  <Header />
  <VStack grow scrollable>
    <LongContent />
  </VStack>
</VStack>

Composition

Use render to choose a semantic element.

Project

Choose where to continue.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { HStack, VStack } from "@/components/ui/stack"

export default function StackRender() {
  return (
    <Card render={<section aria-label="Project" />}>
      <CardHeader>
        <CardTitle render={<h3>Project</h3>} />
      </CardHeader>
      <CardContent>
        <VStack gap={3}>
          <span>Choose where to continue.</span>
          <HStack render={<nav aria-label="Project links" />} gap={4}>
            <a href="#usage">Usage</a>
            <a href="#api-reference">API reference</a>
          </HStack>
        </VStack>
      </CardContent>
    </Card>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Stack

PropTypeDefault
direction"horizontal" | "vertical""vertical"
justify"start" | "center" | "end" | "between" | "around" | "evenly"—
align"start" | "center" | "end" | "stretch" | "baseline"—
gapSpacingStep—
paddingSpacingStep—
paddingInlineSpacingStep—
paddingInlineStartSpacingStep—
paddingInlineEndSpacingStep—
paddingBlockSpacingStep—
paddingBlockStartSpacingStep—
paddingBlockEndSpacingStep—
wrap"nowrap" | "wrap" | "wrap-reverse"—
growbooleanfalse
scrollablebooleanfalse
widthstring | number—
heightstring | number—
maxWidthstring | number—
minHeightstring | number—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

Pass element-specific props on the render element, such as render={<Link to="/" />}. Use a type argument such as VStack<"fieldset"> when passing a fieldset ref.

HStack, VStack

Stack with direction fixed. They take every other Stack prop.

Data attributes

AttributeOnEffect
data-slot="stack"rootTargets the layout
data-orientationrootMirrors direction