@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 } from '@threlte/core'
import { RoundedBoxGeometry, useCursor } from '@threlte/extras'
import { Box } from '@threlte/flex'
import Label from './Label.svelte'
let _class: string
export { _class as class }
export let z = 0
export let text = ''
export let order: number | undefined = undefined
export let onClick: () => void
const { hovering, onPointerEnter, onPointerLeave } = useCursor()
</script>
<Box
class={_class}
{order}
>
{#snippet children({ width, height })}
<T.Mesh
position.z={z}
onclick={(event) => {
event.stopPropagation()
onClick()
}}
onpointerenter={onPointerEnter}
onpointerleave={onPointerLeave}
>
<RoundedBoxGeometry
args={[width, height, 10]}
radius={5}
/>
<T.MeshBasicMaterial color={$hovering ? '#9D9FA3' : '#404550'} />
<Label
z={5.1}
fontSize="xl"
{text}
/>
</T.Mesh>
{/snippet}
</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'
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}
onsync={reflow}
/>
<script lang="ts">
import { T, asyncWritable, useCache } from '@threlte/core'
import { RoundedBoxGeometry, useCursor, useTexture } from '@threlte/extras'
import { spring } from 'svelte/motion'
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 ''
}
}
</script>
{#if $matcapsList}
{@const fileName = `${$matcapsList[String(matcapIndex)]}${getFormatString(format)}.png`}
{@const url = `${matcapRoot}/${format}/${fileName}`}
{#await useTexture(url) then matcap}
<T.Group>
<T.Mesh
scale.x={(width / 100) * $scale}
scale.y={(height / 100) * $scale}
scale.z={$scale}
position.z={20}
onpointerenter={onPointerEnter}
onpointerleave={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 } 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()
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">
{#snippet children({ width, height })}
<Matcap
{width}
{height}
matcapIndex={offset + index}
gridIndex={index}
format={size}
/>
{/snippet}
</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}
onClick={() => {
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}
onClick={() => {
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'
import type { Snippet } from 'svelte'
interface Props {
title: string
width?: number
height?: number
children?: Snippet<[{ width: number; height: number }]>
}
let { title, width = 500, height = 400, children: innerChildren }: Props = $props()
</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">
{#snippet children({ height, 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>
{/snippet}
</Box>
<Box class="h-auto w-auto flex-1">
{#snippet children({ width, height })}
{@render innerChildren?.({ width, height })}
{/snippet}
</Box>
</Flex>
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}
>
{#snippet children({ width, height })}
<Plane
{width}
{height}
/>
{/snippet}
</Box>
<Box
width="auto"
height="auto"
flex={1}
>
{#snippet children({ width, height })}
<Plane
{width}
{height}
/>
{/snippet}
</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}
>
{#snippet children({ width, height })}
<Plane
color="orange"
{width}
{height}
depth={1}
/>
<Box
height="auto"
flex={1}
>
{#snippet children({ width, height })}
<Plane
color="blue"
{width}
{height}
depth={2}
/>
{/snippet}
</Box>
<Box
height="auto"
flex={1}
>
{#snippet children({ width, height })}
<Plane
color="red"
{width}
{height}
depth={2}
/>
{/snippet}
</Box>
{/snippet}
</Box>
<Box
height="auto"
width="auto"
flex={1}
>
{#snippet children({ width, height })}
<Plane
depth={1}
{width}
{height}
/>
{/snippet}
</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}>
{#snippet children({ align })}
<Flex
width={100}
height={100}
onreflow={align}
>
<Box>
<Plane
width={20}
height={20}
/>
</Box>
<Box>
<Plane
width={20}
height={20}
/>
</Box>
</Flex>
{/snippet}
</Align>
class
Using the Prop 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.