threlte logo
@threlte/flex

Getting Started

Placing content and making layouts in 3D is hard. The flexbox engine Yoga is a cross-platform layout engine which implements the flexbox spec. The package @threlte/flex provides components to easily use Yoga in Threlte.

<script lang="ts">
  import { Canvas, T } from '@threlte/core'
  import Scene from './Scene.svelte'
  import { NoToneMapping } from 'three'
  import { Grid, OrbitControls } from '@threlte/extras'
  import { Pane, Slider, List } from 'svelte-tweakpane-ui'

  let innerWidth = 0
  let width = 800
  let height = 800
  let rows = 5
  let columns = 5
  let size = 128
  let sizeOptions = {
    '64px': 64,
    '128px': 128,
    '256px': 256,
    '512px': 512,
    '1024px': 1024
  }
</script>

<Pane
  title="Flex"
  position="fixed"
>
  <Slider
    bind:value={width}
    label="Window Width"
    min={450}
    max={800}
  />
  <Slider
    bind:value={height}
    label="Window Height"
    min={450}
    max={800}
  />
  <Slider
    bind:value={rows}
    label="Rows"
    step={1}
    min={3}
    max={8}
  />
  <Slider
    bind:value={columns}
    label="Columns"
    step={1}
    min={3}
    max={8}
  />
  <List
    bind:value={size}
    label="MatCap Size"
    options={sizeOptions}
  />
</Pane>

<svelte:window bind:innerWidth />

<div>
  <Canvas toneMapping={NoToneMapping}>
    <Grid
      position.z={-10.1}
      plane="xy"
      gridSize={800}
      cellColor="#0A0F19"
      sectionColor="#481D1A"
      sectionSize={100}
      cellSize={10}
      fadeStrength={0}
    />

    <T.OrthographicCamera
      makeDefault
      position.z={1000}
      position.x={500}
      position.y={500}
      zoom={innerWidth / 1200}
    >
      <OrbitControls />
    </T.OrthographicCamera>

    <Scene
      windowWidth={width}
      windowHeight={height}
      {rows}
      {columns}
      {size}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T, forwardEventHandlers } from '@threlte/core'
  import { RoundedBoxGeometry, useCursor } from '@threlte/extras'
  import { Box } from '@threlte/flex'
  import Label from './Label.svelte'

  const component = forwardEventHandlers()

  let _class: string
  export { _class as class }
  export let z = 0
  export let text = ''
  export let order: number | undefined = undefined

  const { hovering, onPointerEnter, onPointerLeave } = useCursor()
</script>

<Box
  class={_class}
  let:width
  let:height
  {order}
>
  <T.Mesh
    bind:this={$component}
    position.z={z}
    on:click={(e) => e.stopPropagation()}
    on:pointerenter={onPointerEnter}
    on:pointerleave={onPointerLeave}
  >
    <RoundedBoxGeometry
      args={[width, height, 10]}
      radius={5}
    />
    <T.MeshBasicMaterial color={$hovering ? '#9D9FA3' : '#404550'} />

    <Label
      z={5.1}
      fontSize="xl"
      {text}
    />
  </T.Mesh>
</Box>
<script lang="ts">
  import { T } from '@threlte/core'

  export let color: string = 'white'
  export let radius = 5
  export let z = 0
</script>

<T.Mesh position.z={z}>
  <T.CircleGeometry args={[radius]} />
  <T.MeshBasicMaterial {color} />
</T.Mesh>
<script lang="ts">
  import { Text } from '@threlte/extras'
  import type { ColorRepresentation } from 'three'
  import { useReflow } from '@threlte/flex'
  import { forwardEventHandlers } from '@threlte/core'

  export let text: string
  export let color: ColorRepresentation = 'white'
  export let z = 0
  export let fontStyle:
    | 'black'
    | 'bold'
    | 'extra-bold'
    | 'extra-light'
    | 'light'
    | 'medium'
    | 'regular'
    | 'semi-bold'
    | 'thin' = 'regular'
  export let anchorX = '50%'
  export let anchorY = '50%'
  export let fontSize: 'xs' | 's' | 'm' | 'l' | 'xl' = 'm'

  const fontSizes: Record<typeof fontSize, number> = {
    xs: 4,
    s: 6,
    m: 8,
    l: 10,
    xl: 12
  }

  $: fontUrl = `/fonts/inter/inter-${fontStyle}.ttf`

  const reflow = useReflow()
</script>

<Text
  font={fontUrl}
  position.z={z}
  {text}
  {anchorX}
  {anchorY}
  fontSize={fontSizes[fontSize]}
  {color}
  on:sync={reflow}
/>
<script lang="ts">
  import { T, asyncWritable, useCache } from '@threlte/core'
  import { RoundedBoxGeometry, createTransition, useCursor, useTexture } from '@threlte/extras'
  import { cubicIn, cubicOut } from 'svelte/easing'
  import { spring } from 'svelte/motion'
  import type { Object3D } from 'three'

  const cache = useCache()

  const matcapsList = asyncWritable(
    cache.remember(async () => {
      const matcapListResponse = await fetch(
        'https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/matcaps.json'
      )
      return (await matcapListResponse.json()) as Record<string, string>
    }, ['matcaps'])
  )

  export let gridIndex: number
  export let matcapIndex: number
  export let format: 64 | 128 | 256 | 512 | 1024 = 256
  export let width = 5
  export let height = 5

  const { onPointerEnter, onPointerLeave, hovering } = useCursor()
  const scale = spring(0.9)
  $: scale.set($hovering ? 1 : 0.9)

  const matcapRoot =
    'https://rawcdn.githack.com/emmelleppi/matcaps/9b36ccaaf0a24881a39062d05566c9e92be4aa0d'

  function getFormatString(fmt: typeof format) {
    switch (fmt) {
      case 64:
        return '-64px'
      case 128:
        return '-128px'
      case 256:
        return '-256px'
      case 512:
        return '-512px'
      default:
        return ''
    }
  }

  const animDelay = gridIndex * 10
  const scaleTransition = createTransition<Object3D>((ref, { direction }) => {
    return {
      tick(t) {
        ref.scale.setScalar(t)
      },
      delay: animDelay + (direction === 'in' ? 200 : 0),
      duration: 200,
      easing: direction === 'in' ? cubicOut : cubicIn
    }
  })
</script>

{#if $matcapsList}
  {@const fileName = `${$matcapsList[String(matcapIndex)]}${getFormatString(format)}.png`}
  {@const url = `${matcapRoot}/${format}/${fileName}`}

  {#await useTexture(url) then matcap}
    <T.Group
      in={scaleTransition}
      out={scaleTransition}
    >
      <T.Mesh
        scale.x={(width / 100) * $scale}
        scale.y={(height / 100) * $scale}
        scale.z={$scale}
        position.z={20}
        on:pointerenter={onPointerEnter}
        on:pointerleave={onPointerLeave}
      >
        <RoundedBoxGeometry
          args={[100, 100, 20]}
          radius={2}
        />
        <T.MeshMatcapMaterial {matcap} />
      </T.Mesh>
    </T.Group>
  {/await}
{/if}
<script lang="ts">
  import { T } from '@threlte/core'

  export let color: string = 'white'
  export let height = 1
  export let width = 1
  export let depth = 0
</script>

<T.Mesh
  position.z={depth * 20}
  renderOrder={depth}
>
  <T.PlaneGeometry args={[width, height]} />

  {#if $$slots.default}
    <slot />
  {:else}
    <T.MeshBasicMaterial
      {color}
      transparent
      opacity={0.5}
    />
  {/if}
</T.Mesh>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Shape, ShapeGeometry } from 'three'

  export let color: string = 'white'
  export let height = 1
  export let width = 1
  export let radius = 5
  export let depth = 0

  let x = 1
  let y = 1

  const createGeometry = (width: number, height: number, radius: number): ShapeGeometry => {
    let shape = new Shape()
    shape.moveTo(x, y + radius)
    shape.lineTo(x, y + height - radius)
    shape.quadraticCurveTo(x, y + height, x + radius, y + height)
    shape.lineTo(x + width - radius, y + height)
    shape.quadraticCurveTo(x + width, y + height, x + width, y + height - radius)
    shape.lineTo(x + width, y + radius)
    shape.quadraticCurveTo(x + width, y, x + width - radius, y)
    shape.lineTo(x + radius, y)
    shape.quadraticCurveTo(x, y, x, y + radius)

    const geometry = new ShapeGeometry(shape)
    geometry.center()
    return geometry
  }

  $: geometry = createGeometry(width, height, radius)
</script>

<T.Mesh
  position.z={depth * 20}
  renderOrder={depth}
>
  <T is={geometry} />
  <T.MeshBasicMaterial {color} />
</T.Mesh>
<script lang="ts">
  import { useTask, useThrelte } from '@threlte/core'
  import { interactivity, transitions } from '@threlte/extras'
  import { Box } from '@threlte/flex'
  import { tick } from 'svelte'
  import Button from './Button.svelte'
  import Label from './Label.svelte'
  import Matcap from './Matcap.svelte'
  import Window from './Window.svelte'

  export let windowWidth: number
  export let windowHeight: number
  export let rows = 5
  export let columns = 5
  export let size: any

  let page = 1
  $: offset = (page - 1) * rows * columns

  interactivity()
  transitions()

  const { renderStage, autoRender, renderer, scene, camera } = useThrelte()

  autoRender.set(false)

  useTask(
    async () => {
      await tick()
      renderer.render(scene, camera.current)
    },
    { stage: renderStage, autoInvalidate: false }
  )
</script>

<Window
  title="Matcaps"
  width={windowWidth}
  height={windowHeight}
>
  <Box class="h-full w-full flex-col items-stretch gap-10 p-10">
    {#each new Array(rows) as _, rowIndex}
      <Box class="h-auto w-full flex-1 items-center justify-evenly gap-10">
        {#each new Array(columns) as _, columnIndex}
          {@const index = rowIndex * columns + columnIndex}
          <Box
            class="h-full w-full flex-1"
            let:width
            let:height
          >
            <Matcap
              {width}
              {height}
              matcapIndex={offset + index}
              gridIndex={index}
              format={size}
            />
          </Box>
        {/each}
      </Box>
    {/each}

    <Box
      order={999}
      class="h-40 w-auto items-center justify-center gap-10"
    >
      <Button
        class="h-full w-auto flex-1"
        z={15}
        text="← PREVIOUS PAGE"
        order={0}
        on:click={() => {
          page = Math.max(1, page - 1)
        }}
      />

      <Box
        class="h-full w-auto flex-1"
        order={1}
      >
        <Label
          z={10.1}
          fontSize="xl"
          text={`PAGE: ${page}`}
        />
      </Box>

      <Button
        class="h-full w-auto flex-1"
        z={15}
        text="NEXT PAGE →"
        order={2}
        on:click={() => {
          page = Math.min(10, page + 1)
        }}
      />
    </Box>
  </Box>
</Window>
<script lang="ts">
  import { T } from '@threlte/core'
  import { RoundedBoxGeometry } from '@threlte/extras'
  import { Box, Flex, tailwindParser } from '@threlte/flex'
  import Circle from './Circle.svelte'
  import Label from './Label.svelte'

  export let title: string
  export let width = 500
  export let height = 400
</script>

<Flex
  classParser={tailwindParser}
  {width}
  {height}
  class="flex-col gap-1 p-1"
>
  <T.Mesh>
    <RoundedBoxGeometry
      args={[width, height, 20]}
      radius={6}
    />
    <T.MeshBasicMaterial color="#0A0F19" />
  </T.Mesh>

  <Box
    class="h-26 pr-53 w-full items-center justify-start gap-5 pl-8"
    let:height
    let:width
  >
    <T.Mesh position.z={20}>
      <RoundedBoxGeometry
        args={[width, height, 20]}
        radius={5}
      />
      <T.MeshBasicMaterial color="#ddd" />
    </T.Mesh>

    <Box class="h-10 w-10">
      <Circle
        radius={5}
        color="#FF6057"
        z={30.01}
      />
    </Box>
    <Box class="h-10 w-10">
      <Circle
        radius={5}
        color="#FDBD2E"
        z={30.01}
      />
    </Box>
    <Box class="h-10 w-10">
      <Circle
        radius={5}
        color="#27C840"
        z={30.01}
      />
    </Box>

    <Box class="h-full w-auto flex-1 items-center justify-center">
      <Label
        text={title}
        z={30.01}
        fontStyle="semi-bold"
        fontSize="l"
        color="#454649"
      />
    </Box>
  </Box>

  <Box
    class="h-auto w-auto flex-1"
    let:width
    let:height
  >
    <slot
      {width}
      {height}
    />
  </Box>
</Flex>
MatCap textures from https://github.com/emmelleppi/matcaps

Installation

npm install @threlte/flex

Usage

Basic Example

Use the component <Flex> to create a flexbox container. Since there’s no viewport to fill, you must specify the size of the container. Add flex items with the component <Box>.

<script lang="ts">
  import { Flex } from '@threlte/flex'
  import Plane from './Plane.svelte'
</script>

<Flex
  width={100}
  height={100}
>
  <Box>
    <Plane
      width={20}
      height={20}
    />
  </Box>

  <Box>
    <Plane
      width={20}
      height={20}
    />
  </Box>
</Flex>

Flex Props

The components <Flex> and <Box> accept props to configure the flexbox. If no width or height is specified on <Box> components, a bounding box is used to determine the size of the flex item. The computed width or height may be different from what is specified on the <Box> component, depending on the flexbox configuration. To make use of the calculated dimensions of a flex item, use the slot props width and height.

<Flex
  width={100}
  height={100}
  flexDirection="Column"
  justifyContent="SpaceEvenly"
  alignItems="Stretch"
>
  <Box
    width="auto"
    height="auto"
    flex={1}
    let:width
    let:height
  >
    <Plane
      {width}
      {height}
    />
  </Box>

  <Box
    width="auto"
    height="auto"
    flex={1}
    let:width
    let:height
  >
    <Plane
      {width}
      {height}
    />
  </Box>
</Flex>

Nested Flex

Every <Box> component is also a flex container. Nesting <Box> components allows you to create complex layouts.

<Flex
  width={100}
  height={100}
  flexDirection="Column"
  justifyContent="SpaceEvenly"
  alignItems="Stretch"
>
  <Box
    width="auto"
    height="auto"
    flex={1}
    justifyContent="SpaceEvenly"
    alignItems="Stretch"
    padding={20}
    margin={20}
    gap={20}
    let:width
    let:height
  >
    <Plane
      color="orange"
      {width}
      {height}
      depth={1}
    />
    <Box
      height="auto"
      flex={1}
      let:width
      let:height
    >
      <Plane
        color="blue"
        {width}
        {height}
        depth={2}
      />
    </Box>

    <Box
      height="auto"
      flex={1}
      let:width
      let:height
    >
      <Plane
        color="red"
        {width}
        {height}
        depth={2}
      />
    </Box>
  </Box>

  <Box
    height="auto"
    width="auto"
    flex={1}
    let:width
    let:height
  >
    <Plane
      depth={1}
      {width}
      {height}
    />
  </Box>
</Flex>

Align Flex Container

The component <Align> can be used to align the resulting flex container.

<script lang="ts">
  import { Align } from '@threlte/extras'
  import { Flex } from '@threlte/flex'
  import Plane from './Plane.svelte'
</script>

<Align
  y={1}
  let:align
>
  <Flex
    width={100}
    height={100}
    on:reflow={align}
  >
    <Box>
      <Plane
        width={20}
        height={20}
      />
    </Box>

    <Box>
      <Plane
        width={20}
        height={20}
      />
    </Box>
  </Flex>
</Align>

Using the Prop class

The prop class can be used on <Box> and <Flex> to easily configure the flexbox with predefined class names just as you would do in CSS. In order to use the prop, you need to create a ClassParser using the utility createClassParser which accepts a single string and returns NodeProps. Let’s assume, you want to create a parser that supports the following class names:

.container {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: stretch;
  gap: 10px;
  padding: 10px;
}
.item {
  width: auto;
  height: auto;
  flex: 1;
}

You then need to create a ClassParser which returns the corresponding props:

import { createClassParser } from '@threlte/flex'

const classParser = createClassParser((string, props) => {
  const classNames = string.split(' ')
  for (const className of classNames) {
    switch (className) {
      case 'container':
        props.flexDirection = 'Row'
        props.justifyContent = 'Center'
        props.alignItems = 'Stretch'
        props.gap = 10
        props.padding = 10
        break
      case 'item':
        props.width = 'auto'
        props.height = 'auto'
        props.flex = 1
    }
  }
  return props
})

Now you can use the prop class on <Flex> and <Box> to configure the flexbox:

<Flex
  width={100}
  height={100}
  {classParser}
  class="container"
>
  <Box class="item">
    <Plane
      width={20}
      height={20}
    />
  </Box>

  <Box class="item">
    <Plane
      width={20}
      height={20}
    />
  </Box>
</Flex>

@threlte/flex ships with a default ClassParser which supports Tailwind-like class names.