├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src ├── Box │ ├── Box.css.ts │ └── Box.tsx ├── Breakout │ ├── Breakout.css.ts │ └── Breakout.tsx ├── Clamp │ ├── Clamp.css.ts │ └── Clamp.tsx ├── Columns │ ├── Columns.css.ts │ └── Columns.tsx ├── DebugProvider │ ├── DebugProvider.css.ts │ └── DebugProvider.tsx ├── Grid │ ├── Grid.css.ts │ └── Grid.tsx ├── Row │ ├── Row.css.ts │ └── Row.tsx ├── Split │ ├── Split.css.ts │ └── Split.tsx ├── Stack │ ├── Stack.css.ts │ └── Stack.tsx ├── component-icons.png ├── favicon.svg ├── hooks │ └── useAlign.ts ├── ideas.md ├── index.ts ├── logo.svg ├── main.tsx ├── useRatio.ts ├── utils │ └── toSelectorString.ts └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Christian Kaindl 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

LYTS

3 | Abstract illustrations depicting the available layout components 4 |

Layout primitives for React.

5 |
6 | 7 | # LYTS 8 | 9 | Layout primitives to build any kind of layout with useful props like `bleed`, `asChild` and `xAlign`/`yAlign`. 10 | 11 | - **Like Lego** – Compose primitives to create complex layouts 12 | - **Tiny bundle** – Only 445 Bytes total ([source](https://bundlephobia.com/package/@christiankaindl/lyts@2.0.0-beta.3)) 13 | - **Unstyled** – Bring your own styling solution—Tailwind, CSS Modules, you name it 14 | - **Layout props** – Simple & productive API 15 | 16 | ⚛️ [Components API](https://lyts.christiankaindl.com/components) · 📚 [Guides](https://lyts.christiankaindl.com/guides) · 📖 [Examples](https://lyts.christiankaindl.com/examples) 17 | 18 | To get started, import a base component and compose them together—Stack, Row, Clamp, Columns, Grid—happy layout building! 19 | 20 | > [!NOTE] 21 | > Version 2.0 introduced support for React 19, and migrated away from using `forwardRef()`. This means v2.0 may not work as intended when using with React 18 or earlier. If you want to pass `ref`s to LYTS components and use React 18 or lower, consider using v1.2.0 instead, which has full support. 22 | 23 | ## Usage 24 | 25 | Layout components can be composed until you achieve your desired layout. For example, The following \ component renders a card with a max-width of 400px, centers it and uses a Stack to get consistent spacing: 26 | 27 | ![image]() 28 | 29 | ```jsx 30 | const CenterCard: FunctionComponent = function () { 31 | return ( 32 | // A card with clamped 400px and centered 33 | 34 | 35 |

Card title

36 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

37 |
38 |
39 | ) 40 | } 41 | ``` 42 | 43 | Check out the Examples page for a comprehensive collection of layouts and how to build them with LYTS. 44 | 45 | A real-world example using LYTS is this documentation site, which makes extensive use of all components. [Check out the code here](https://github.com/christiankaindl/LYTS-website/)! 46 | 47 | ## Support & help 48 | 49 | If you get stuck, [reach out to @christiankaindl](https://twitter.com/christiankaindl) on Twitter. In case of bugs, [open an issue on GitHub](https://github.com/christiankaindl/LYTS/issues). 50 | 51 | ## Local Development 52 | 53 | ```sh 54 | npm install 55 | npm run dev 56 | ``` 57 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@christiankaindl/lyts", 3 | "description": "Layout primitives for React", 4 | "version": "2.0.1", 5 | "author": "Christian Kaindl", 6 | "license": "MIT", 7 | "homepage": "https://lyts.christiankaindl.com", 8 | "bugs": "https://github.com/christiankaindl/LYTS/issues", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/christiankaindl/LYTS.git" 12 | }, 13 | "scripts": { 14 | "dev": "vite", 15 | "build": "tsc && vite build", 16 | "preview": "vite preview" 17 | }, 18 | "prepublishOnly": "npm run build", 19 | "files": [ 20 | "dist" 21 | ], 22 | "type": "module", 23 | "main": "./dist/lyts.js", 24 | "types": "./dist/index.d.ts", 25 | "exports": { 26 | "./style.css": "./dist/style.css", 27 | ".": { 28 | "types": "./dist/index.d.ts", 29 | "default": "./dist/lyts.js" 30 | } 31 | }, 32 | "sideEffects": [ 33 | "**/*.css" 34 | ], 35 | "dependencies": { 36 | "@radix-ui/react-slot": "^1.1.1", 37 | "@vanilla-extract/dynamic": "^2.1.2" 38 | }, 39 | "peerDependencies": { 40 | "react": "^16.8 || ^17.0 || ^18.0 || ^19.0", 41 | "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0" 42 | }, 43 | "devDependencies": { 44 | "@radix-ui/colors": "^0.1.8", 45 | "@types/react": "^19.0.1", 46 | "@types/react-dom": "^19.0.2", 47 | "@vanilla-extract/css": "^1.16.1", 48 | "@vanilla-extract/css-utils": "^0.1.4", 49 | "@vanilla-extract/sprinkles": "^1.6.3", 50 | "@vanilla-extract/vite-plugin": "^4.0.18", 51 | "@vitejs/plugin-react": "^4.3.4", 52 | "react": "^19.0.0", 53 | "react-dom": "^19.0.0", 54 | "typescript": "^5.7.2", 55 | "vite": "5.4.11", 56 | "vite-plugin-dts": "^4.3.0", 57 | "vite-plugin-lib-inject-css": "^2.1.1", 58 | "vite-tsconfig-paths": "^5.1.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Box/Box.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, style } from "@vanilla-extract/css"; 2 | import { calc } from '@vanilla-extract/css-utils' 3 | 4 | export const vars = { 5 | gap: createVar('gap'), 6 | alignItems: createVar('align-items'), 7 | justifyContent: createVar('justify-content'), 8 | flexDirection: createVar('flex-direction'), 9 | bleedTop: createVar('bleed-top'), 10 | bleedRight: createVar('bleed-right'), 11 | bleedBottom: createVar('bleed-bottom'), 12 | bleedLeft: createVar('bleed-left'), 13 | } 14 | 15 | export const box = style({ 16 | display: 'flex', 17 | // Set default values so that values don't propagate beyond the element where props are applied, making it possible to reduce the amount of inline CSS variables 18 | vars: { 19 | [vars.gap]: '1rem', 20 | [vars.alignItems]: 'initial', 21 | [vars.justifyContent]: 'initial', 22 | [vars.flexDirection]: 'column', 23 | [vars.bleedTop]: '0px', 24 | [vars.bleedRight]: '0px', 25 | [vars.bleedBottom]: '0px', 26 | [vars.bleedLeft]: '0px' 27 | }, 28 | gap: vars.gap, 29 | alignItems: vars.alignItems, 30 | justifyContent: vars.justifyContent, 31 | flexDirection: vars.flexDirection, 32 | // Make sure horizontal alignment is applied to Grid elements (like Clamp and Grid) 33 | // `justifyItems` does nothing in flexbox contexts 34 | justifyItems: vars.justifyContent, 35 | marginTop: calc(vars.bleedTop).negate().toString(), 36 | marginRight: calc(vars.bleedRight).negate().toString(), 37 | marginBottom: calc(vars.bleedBottom).negate().toString(), 38 | marginLeft: calc(vars.bleedLeft).negate().toString(), 39 | }) 40 | -------------------------------------------------------------------------------- /src/Box/Box.tsx: -------------------------------------------------------------------------------- 1 | import { useAlign } from "@lib/hooks/useAlign"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { assignInlineVars } from "@vanilla-extract/dynamic"; 4 | import { CSSProperties, type Ref } from "react" 5 | import * as styles from './Box.css' 6 | 7 | // TODO: Add `inline` prop? 8 | export interface BoxProps extends React.HTMLAttributes { 9 | ref?: Ref 10 | /** 11 | * Amount of space between elements. Same as CSS' `gap`. 12 | * @docs https://developer.mozilla.org/en-US/docs/Web/CSS/gap 13 | */ 14 | gap?: CSSProperties['gap'] 15 | /** 16 | * Forward props onto its immediate child (must be the only child) 17 | * 18 | * When `true`, the first child is used as the container. Useful for writing semantic HTML with correct element behavior (keyboard interactions, screen-readers). 19 | * Inspired by Radix UI's API. 20 | * 21 | * @docs https://www.radix-ui.com/docs/primitives/utilities/slot 22 | */ 23 | asChild?: boolean 24 | /** Alias for the CSS `flex-direction` property */ 25 | orientation?: 'row' | 'column' 26 | /** Visually break out of the parent's box. Useful for visually aligning e.g. transparent buttons */ 27 | bleed?: string | number 28 | /** Top `bleed` value @see bleed */ 29 | bleedTop?: CSSProperties['padding'] 30 | /** Right `bleed` value @see bleed */ 31 | bleedRight?: CSSProperties['padding'] 32 | /** Bottom `bleed` value @see bleed */ 33 | bleedBottom?: CSSProperties['padding'] 34 | /** Left `bleed` value @see bleed */ 35 | bleedLeft?: CSSProperties['padding'] 36 | /** 37 | * Horizontal alignment. 38 | * 39 | * Same as CSS' `align-items` property when `orientation='row'`. 40 | * Same as CSS' `justify-content` property when `orientation='column'`. 41 | */ 42 | xAlign?: Direction extends 'row' 43 | ? CSSProperties['justifyContent'] 44 | : CSSProperties['alignItems'] 45 | /** 46 | * Vertical alignment. 47 | * 48 | * Same as CSS' `justify-content` property when `orientation='row'`. 49 | * Same as CSS' `align-items` property when `orientation='column'`. 50 | */ 51 | yAlign?: Direction extends 'row' 52 | ? CSSProperties['alignItems'] 53 | : CSSProperties['justifyContent'] 54 | } 55 | 56 | export const boxStyles = styles ?? {} 57 | 58 | /** 59 | * Generic flexbox context with convenience props such as `xAlign`/`yAlign`for general alignment, `bleed` for visual alignment and `asChild` to customize the rendered element. 60 | */ 61 | export function Box ({ 62 | children, 63 | gap, 64 | asChild = false, 65 | bleed, 66 | bleedTop, 67 | bleedRight, 68 | bleedBottom, 69 | bleedLeft, 70 | style = {}, 71 | orientation = 'column', 72 | xAlign = 'initial', 73 | yAlign = 'initial', 74 | ...props 75 | }: BoxProps) { 76 | const Comp = asChild ? Slot : 'div'; 77 | const align = useAlign(orientation, xAlign, yAlign) 78 | 79 | if (typeof bleed === 'number') { 80 | bleed = `${bleed}px` 81 | } 82 | const _bleed = bleed ? bleed.split(' ') : [] 83 | bleedTop = bleedTop ?? _bleed?.[0] 84 | bleedRight = bleedRight ?? _bleed?.[1] ?? _bleed?.[0] 85 | bleedBottom = bleedBottom ?? _bleed?.[2] ?? _bleed?.[0] 86 | bleedLeft = bleedLeft ?? _bleed?.[3] ?? _bleed?.[1] ?? _bleed?.[0] 87 | 88 | // Only set Create an object that only contains properties which are set 89 | const inlineVars = Object.assign({}, 90 | gap !== undefined && { [styles.vars.gap]: typeof gap === 'number' ? `${gap}rem` : gap }, 91 | bleedTop && { [styles.vars.bleedTop]: toCssValue(bleedTop) }, 92 | bleedRight && { [styles.vars.bleedRight]: toCssValue(bleedRight) }, 93 | bleedBottom && { [styles.vars.bleedBottom]: toCssValue(bleedBottom) }, 94 | bleedLeft && { [styles.vars.bleedLeft]: toCssValue(bleedLeft) }, 95 | align.justifyContent !== 'initial' && { [styles.vars.justifyContent]: align.justifyContent }, 96 | align.alignItems !== 'initial' && { [styles.vars.alignItems]: align.alignItems }, 97 | align.flexDirection !== 'column' && { [styles.vars.flexDirection]: align.flexDirection } 98 | ) 99 | 100 | return ( 101 | 109 | {children} 110 | 111 | ) 112 | } 113 | 114 | export default Box 115 | 116 | function toCssValue (value: string | number) { 117 | return (typeof value === 'number' || value === '0') ? `${value}px` : value 118 | } 119 | -------------------------------------------------------------------------------- /src/Breakout/Breakout.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | export const breakout = style({ 4 | gridColumn: '1 / -1' 5 | }) 6 | -------------------------------------------------------------------------------- /src/Breakout/Breakout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from "@lib/Box/Box"; 2 | import * as styles from './Breakout.css' 3 | 4 | export const breakoutStyles = styles ?? {} 5 | 6 | /** 7 | * Spans the full row in a CSS Grid context. Use within a `` or `` to make elements full-width. 8 | * 9 | * @see Clamp 10 | * @see Grid 11 | */ 12 | export function Breakout ({ children, ...props }: BoxProps) { 13 | return ( 14 | 18 | {children} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/Clamp/Clamp.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, fallbackVar, globalStyle, style } from '@vanilla-extract/css' 2 | 3 | export const maxWidth = createVar('max-width') 4 | export const maxHeight = createVar('max-height') 5 | 6 | // TODO: Make more generic, by mirroring the technique for horizontal clamping 7 | // So, set gridTemplateRows when vertical clamping is used. This would unify how alignment works across horizontal/vertical clamping 8 | // and also fix yAlign, which currenlty does nothing on Clamp. 9 | 10 | // But that would (I think) also mean that Clamp can't support the `gap` prop anymore, as it has to be 0 (or otherwise it grows too large) 11 | // A workaround for this could be to dynamically replace "100%" with a calc: "1fr min(${fallbackVar(maxWidth, '100%')}, calc(100% - ${gap}*2)) 1fr" 12 | export const clamp = style({ 13 | display: 'grid', 14 | gridTemplateColumns: `1fr min(${fallbackVar(maxWidth, '100%')}, 100%) 1fr`, 15 | columnGap: 0, // Update when uni-directional clamping is implemented 16 | // Only applies when the container has an explicit height, or vertical clamping is used 17 | alignItems: 'center' 18 | }) 19 | 20 | globalStyle(`${clamp} > *`, { 21 | gridColumn: 2, 22 | maxHeight, 23 | boxSizing: 'border-box' 24 | }) 25 | -------------------------------------------------------------------------------- /src/Clamp/Clamp.tsx: -------------------------------------------------------------------------------- 1 | import { Children } from 'react' 2 | import * as styles from './Clamp.css' 3 | import { assignInlineVars } from '@vanilla-extract/dynamic' 4 | import Box, { BoxProps } from '@lib/Box/Box' 5 | 6 | export interface ClampProps extends BoxProps { 7 | /** 8 | * The maximum width (or height) of its children. 9 | * 10 | * If an array with two values are passed, the second value is used as the maximum height. In that case, you can also set the first value to `null` to get only vertical centering. 11 | * 12 | * Note: When vertical clamping is used, only a single direct child is supported. 13 | */ 14 | clamp: string | [maxWidth: string | null, maxHeight: string] 15 | } 16 | 17 | export const clampStyles = styles ?? {} 18 | 19 | /** 20 | * Center-constrained component, supporting both horizontal and vertical clamping. Individual children can "opt out" of the clamping with the `` component. 21 | * 22 | * @see https://www.joshwcomeau.com/css/full-bleed/ for a detailed explanation of how Clamp and Breakout work internally. 23 | */ 24 | export function Clamp ({ 25 | children, 26 | clamp, 27 | style = {}, 28 | ...props 29 | }: ClampProps) { 30 | const isArray = Array.isArray(clamp) 31 | const clampWidth = isArray ? clamp[0] : clamp 32 | const clampHeight = isArray ? clamp[1] : undefined 33 | 34 | if (clampHeight !== undefined && Children.count(children) > 1) { 35 | throw Error(` with a vertical clamp set must be used with a single child only. Current children: ${Children.count(children)}`) 36 | } 37 | 38 | return ( 39 | 51 | {children} 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/Columns/Columns.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, fallbackVar, globalStyle, style } from '@vanilla-extract/css' 2 | 3 | export const flexGrow = createVar('flex-grow') 4 | export const collapseAt = createVar('collapse-at') 5 | 6 | export const columns = style({ 7 | flexWrap: 'wrap' 8 | }) 9 | 10 | globalStyle(`${columns} > *`, { 11 | flexGrow: fallbackVar(flexGrow, '1'), 12 | // Holy Albatross technique: https://heydonworks.com/article/the-flexbox-holy-albatross-reincarnated/ 13 | flexBasis: `calc((${collapseAt} - 100%) * 999)`, 14 | width: 0, 15 | boxSizing: 'border-box' 16 | }) 17 | -------------------------------------------------------------------------------- /src/Columns/Columns.tsx: -------------------------------------------------------------------------------- 1 | import { Children, cloneElement, isValidElement, type CSSProperties, type ReactElement } from 'react' 2 | import * as styles from './Columns.css' 3 | import Box, { BoxProps } from '@lib/Box/Box' 4 | import { useRatio } from '@lib/useRatio' 5 | import { assignInlineVars } from '@vanilla-extract/dynamic' 6 | 7 | export interface ColumnsProps extends BoxProps { 8 | /** 9 | * Control the proportional distribution of the columns. By default, all columns are distributed equally. 10 | * 11 | * Needs to be set to a string in the format `${number}[.../${number}]` each value separated with a slash "/" is used as a proportion for the `flex-grow` value. 12 | * For example, a `ratio` value of "1/1.5/1" will grow the middle child 1.5 times more than the other two elements (assuming there are 3 children). 13 | * 14 | * Note: When using the `ratio` prop, make sure that all direct children forward the `style` prop to the underlying DOM element. 15 | */ 16 | ratio?: string 17 | /** 18 | * Collapse children into a vertical layout at this width. 19 | * 20 | * Note that `collapseAt` is based on the total element width, and **not** the window width. 21 | */ 22 | collapseAt?: string 23 | } 24 | 25 | export const columnsStyles = styles ?? {} 26 | 27 | /** 28 | * Extrinsicly sized columns, filling the whole available space and wrapping all-at-once when the `collapseAt` value is reached. Space distribution can be customized with the `ratio` prop. 29 | */ 30 | export function Columns ({ 31 | children, 32 | ratio, 33 | collapseAt = '0', 34 | ...props 35 | }: ColumnsProps) { 36 | const ratios = useRatio(ratio) 37 | return ( 38 | 49 | {ratios === undefined 50 | ? children 51 | : Children.map(children, (child, index) => { 52 | if (!child) return null 53 | if (!isValidElement(child)) return child 54 | return ( 55 | cloneElement(child as ReactElement<{ style: CSSProperties }>, { 56 | style: { 57 | ...(child as ReactElement<{ style: CSSProperties }>).props.style, 58 | ...assignInlineVars({ 59 | [styles.flexGrow]: typeof ratios === 'string' 60 | ? ratios 61 | : (ratios?.[index] || '1') 62 | }) 63 | } 64 | }) 65 | ) 66 | })} 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/DebugProvider/DebugProvider.css.ts: -------------------------------------------------------------------------------- 1 | // import { boxStyles, stackStyles, rowStyles, clampStyles, columnsStyles, gridStyles } from "@christiankaindl/lyts" 2 | 3 | import { blueA, crimsonA, mauveA, plumA, redA, tealA, tomatoA, violetA } from "@radix-ui/colors"; 4 | import { globalStyle, style } from "@vanilla-extract/css"; 5 | import { boxStyles } from "../Box/Box"; 6 | import { stackStyles } from "../Stack/Stack"; 7 | import { rowStyles } from "../Row/Row"; 8 | import { clampStyles } from "../Clamp/Clamp"; 9 | import { columnsStyles } from "../Columns/Columns"; 10 | import { gridStyles } from "../Grid/Grid"; 11 | 12 | globalStyle('html, body', { 13 | margin: 0, 14 | fontFamily: 'sans-serif' 15 | }) 16 | 17 | export const debug = style({ 18 | display: 'contents' 19 | }) 20 | globalStyle(`${debug} ${toClassName(boxStyles.box)}`, { 21 | outline: `2px solid ${mauveA.mauveA6}`, 22 | padding: 18, 23 | borderRadius: 15, 24 | backgroundColor: mauveA.mauveA2 25 | }) 26 | globalStyle(`${debug} ${toClassName(boxStyles.box)} > ${toClassName(boxStyles.box)}`, { 27 | borderRadius: 9 28 | }) 29 | 30 | globalStyle(`${debug} ${toClassName(stackStyles.stack)}`, { 31 | // outline: '2px solid #f9c6c6', // Red 6 32 | outline: `2px solid ${redA.redA6}`, 33 | backgroundImage: `linear-gradient(160deg, ${tomatoA.tomatoA1}, ${crimsonA.crimsonA1})` 34 | }) 35 | 36 | globalStyle(`${debug} ${toClassName(rowStyles.row)}`, { 37 | outline: '2px solid #f3c6e2', 38 | backgroundImage: `linear-gradient(160deg, ${crimsonA.crimsonA1}, ${plumA.plumA1})` 39 | }) 40 | 41 | globalStyle(`${debug} ${toClassName(clampStyles.clamp)}`, { 42 | outline: '2px solid #e3ccf4', 43 | backgroundImage: `linear-gradient(160deg, ${plumA.plumA1}, ${violetA.violetA1})` 44 | }) 45 | 46 | globalStyle(`${debug} ${toClassName(columnsStyles.columns)}`, { 47 | outline: '2px solid #c6d4f9', 48 | backgroundImage: `linear-gradient(160deg, ${violetA.violetA1}, ${blueA.blueA1})` 49 | }) 50 | 51 | globalStyle(`${debug} ${toClassName(gridStyles.grid)}`, { 52 | outline: '2px solid #aadee6', 53 | backgroundImage: `linear-gradient(160deg, ${blueA.blueA1}, ${tealA.tealA1})` 54 | }) 55 | 56 | export const nav = style([debug, { 57 | borderRadius: 24, 58 | justifyContent: 'center', 59 | backgroundColor: 'rgb(255, 255, 255, 0.25)', 60 | // backdropFilter: 'blur(10px)', 61 | boxShadow: '0 5px 24px -7px rgb(0 0 0 / 0.1)', 62 | padding: 18 63 | }]) 64 | 65 | // Convert class name string as used in HTML's `class` attribute (separated by a space " ") to the CSS dot-notation so that they can be composed with vanilla extract more easily 66 | function toClassName (htmlClassNames: string) { 67 | return `.${htmlClassNames.split(' ').join('.')}` 68 | } 69 | -------------------------------------------------------------------------------- /src/DebugProvider/DebugProvider.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, PropsWithChildren } from "react"; 2 | import { debug } from './DebugProvider.css' 3 | 4 | /** 5 | * Render all children LYTS components with extra visual cues 6 | * @private 7 | */ 8 | const DebugProvider: FunctionComponent = function DebugProvider ({ children }) { 9 | return ( 10 |
11 | {children} 12 |
13 | ) 14 | } 15 | 16 | export default DebugProvider 17 | -------------------------------------------------------------------------------- /src/Grid/Grid.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, style } from '@vanilla-extract/css' 2 | 3 | export const gridItemMinWidth = createVar('gridItemMinWidth') 4 | export const gridMaxRowItems = createVar('gridMaxRowItems') 5 | 6 | export const grid = style({ 7 | display: 'grid', 8 | gridTemplateColumns: `repeat(auto-fit, minmax(min(${gridItemMinWidth}, 100%), 1fr))` 9 | }) 10 | -------------------------------------------------------------------------------- /src/Grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import { Children } from 'react' 2 | import * as styles from './Grid.css' 3 | import { assignInlineVars } from '@vanilla-extract/dynamic' 4 | import Box, { BoxProps } from '@lib/Box/Box' 5 | 6 | export interface GridProps extends BoxProps { 7 | gridItemMinWidth?: string 8 | /** TODO: Different columns API? See https://css-tricks.com/responsive-layouts-fewer-media-queries/ */ 9 | gridMaxRowItems?: number 10 | } 11 | 12 | export const gridStyles = styles ?? {} 13 | 14 | /** 15 | * Grid layout with with responsive defaults, but also fully customizable with standard CSS grid properties. 16 | */ 17 | export function Grid ({ 18 | children, 19 | gridItemMinWidth = '300px', 20 | gridMaxRowItems, 21 | style = {}, 22 | ...props 23 | }: GridProps) { 24 | return ( 25 | 37 | {children} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/Row/Row.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, fallbackVar, globalStyle, style } from '@vanilla-extract/css' 2 | 3 | export const flexGrow = createVar('flex-grow') 4 | export const wrap = createVar('wrap') 5 | 6 | export const row = style({ 7 | flexWrap: wrap, 8 | }) 9 | 10 | globalStyle(`${row} > *`, { 11 | flexGrow: fallbackVar(flexGrow, '0') 12 | }) 13 | -------------------------------------------------------------------------------- /src/Row/Row.tsx: -------------------------------------------------------------------------------- 1 | import { Children, cloneElement, CSSProperties, isValidElement, type ReactElement } from 'react' 2 | import * as styles from './Row.css' 3 | import { assignInlineVars } from '@vanilla-extract/dynamic' 4 | import Box, { BoxProps } from '@lib/Box/Box' 5 | import { useRatio } from '@lib/useRatio' 6 | 7 | export interface RowProps extends BoxProps { 8 | /** 9 | * Same as CSS' `flex-wrap` property. 10 | * @docs https://developer.mozilla.org/en-US/docs/Web/CSS/flex-wrap 11 | */ 12 | wrap?: boolean | CSSProperties['flexWrap'] 13 | /** 14 | * Grow the Row's children to fill the container's width. 15 | * 16 | * If a string is supplied in the format `${number}[.../${number}]` each value separated with a slash "/" is used as a proportion for the `flex-grow` value. 17 | * For example, an `expandChildren` value of "1/1.5/1" will grow the middle child 1.5 times more than the other two elements (assuming there are 3 children). 18 | * 19 | * @see `ratio` in Columns props 20 | */ 21 | expandChildren?: boolean | string 22 | } 23 | 24 | export const rowStyles = styles ?? {} 25 | 26 | /** 27 | * Horizontally stacked components, with convenience `wrap` and `expand` props. By default, all children are vertically centered and horizontally start-aligned. 28 | */ 29 | export function Row ({ 30 | children, 31 | wrap = 'nowrap', 32 | expandChildren = false, 33 | yAlign = 'center', 34 | style = {}, 35 | ...props 36 | }: RowProps) { 37 | if (wrap === true) { 38 | wrap = 'wrap' 39 | } else if (wrap === false) { 40 | wrap = 'nowrap' 41 | } 42 | const ratios = useRatio(expandChildren) 43 | return ( 44 | 56 | {ratios === undefined 57 | ? children 58 | // TODO: Only need to map the children like this if expandChildren is a string. 59 | : Children.map(children, (child, index) => { 60 | if (!child) return null 61 | if (!isValidElement(child)) return child 62 | return ( 63 | cloneElement(child as ReactElement<{ style: CSSProperties }>, { 64 | style: { 65 | ...(child as ReactElement<{ style: CSSProperties }>).props.style, 66 | ...assignInlineVars({ 67 | [styles.flexGrow]: typeof ratios === 'string' 68 | ? ratios 69 | : (ratios?.[index] || '0') 70 | }) 71 | } 72 | }) 73 | ) 74 | }) 75 | } 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /src/Split/Split.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css' 2 | 3 | export const split = style({ 4 | flexGrow: 1 5 | }) 6 | -------------------------------------------------------------------------------- /src/Split/Split.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './Split.css' 2 | import { BoxProps } from '@lib/Box/Box' 3 | 4 | export interface SplitProps extends BoxProps { 5 | children?: undefined 6 | } 7 | 8 | export const splitStyles = styles ?? {} 9 | 10 | /** 11 | * Helper to separate items along a single axis. Split takes no children and composes with ``, `` or any CSS flexbox context. 12 | */ 13 | export function Split ({ 14 | style = {}, 15 | ...props 16 | }) { 17 | return ( 18 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/Stack/Stack.css.ts: -------------------------------------------------------------------------------- 1 | import { createVar, fallbackVar, globalStyle, style } from '@vanilla-extract/css' 2 | 3 | export const flexGrow = createVar('flex-grow') 4 | 5 | export const stack = style({}) 6 | 7 | globalStyle(`${stack} > *`, { 8 | flexGrow: fallbackVar(flexGrow, '0') 9 | }) 10 | -------------------------------------------------------------------------------- /src/Stack/Stack.tsx: -------------------------------------------------------------------------------- 1 | import { Children, cloneElement, isValidElement, type CSSProperties, type ReactElement } from 'react' 2 | import * as styles from './Stack.css' 3 | import Box, { BoxProps } from '@lib/Box/Box' 4 | import { useRatio } from '@lib/useRatio' 5 | import { assignInlineVars } from '@vanilla-extract/dynamic' 6 | 7 | export interface StackProps extends BoxProps<'column'> { 8 | /** 9 | * Grow the Stack's children to fill the container's height. 10 | * 11 | * If a string is supplied in the format `${number}[.../${number}]` each value separated with a slash "/" is used as a proportion for the `flex-grow` value. 12 | * For example, an `expandChildren` value of "1/1.5/1" will grow the middle child 1.5 times more than the other two elements (assuming there are 3 children). 13 | * 14 | * @see `ratio` Columns 15 | */ 16 | expandChildren?: boolean | string 17 | } 18 | 19 | export const stackStyles = styles ?? {} 20 | 21 | /** 22 | * Vertically stacked elements, taking up the full width by default. Best nested within other Stacks. 23 | */ 24 | export function Stack ({ 25 | children, 26 | expandChildren = false, 27 | ...props 28 | }: StackProps) { 29 | const ratios = useRatio(expandChildren) 30 | return ( 31 | 36 | {ratios === undefined 37 | ? children 38 | // TODO: Only need to map the children like this is expandChildren is a string. 39 | : Children.map(children, (child, index) => { 40 | if (!child) return null 41 | if (!isValidElement(child)) return child 42 | return ( 43 | cloneElement(child as ReactElement<{ style: CSSProperties }>, { 44 | style: { 45 | ...(child as ReactElement<{ style: CSSProperties }>).props.style, 46 | ...assignInlineVars({ 47 | [styles.flexGrow]: typeof ratios === 'string' 48 | ? ratios 49 | : (ratios?.[index] || '0') 50 | }) 51 | } 52 | }) 53 | ) 54 | })} 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/component-icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiankaindl/LYTS/270d8e2c4df5776e9eb68b7d87826c20f542f067/src/component-icons.png -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/hooks/useAlign.ts: -------------------------------------------------------------------------------- 1 | export function useAlign (orientation: 'row' | 'column', xAlign: string, yAlign: string) { 2 | return { 3 | flexDirection: orientation, 4 | alignItems: orientation === 'row' ? yAlign : xAlign, 5 | justifyContent: orientation === 'row' ? xAlign : yAlign 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/ideas.md: -------------------------------------------------------------------------------- 1 | - Add new `clamp` prop to Stack, Row and Columns out-of-the-box. If yes, then that would automatically wrap them in Clamp 2 | - Columns "auto" keyword? 3 | - Columns "/"-syntax confusing? 4 | - Columns "style"-forwarding example would be good 5 | - Columns xAlign doesn't do anything? (only has effect on mobile) 6 | - Merge Columns and Row into one? -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export const lytsId = Symbol('Identifier for LYTS React components') 2 | 3 | export * from './Box/Box' 4 | export * from './Stack/Stack' 5 | export * from './Row/Row' 6 | export * from './Clamp/Clamp' 7 | export * from './Columns/Columns' 8 | export * from './Grid/Grid' 9 | export * from './Split/Split' 10 | export * from './Breakout/Breakout' 11 | 12 | export { toSelectorString } from './utils/toSelectorString' 13 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import React, { FunctionComponent, useLayoutEffect, useRef, useState, type RefObject } from 'react' 3 | import { Stack, Row, Clamp, Columns, Grid, Box, Breakout, Split } from './' 4 | import DebugProvider from './DebugProvider/DebugProvider' 5 | 6 | function ForwardRefTest () { 7 | const ref = useRef(null) 8 | const [height, setHeight] = useState() 9 | 10 | useLayoutEffect(() => { 11 | setHeight(ref.current?.clientHeight) 12 | }) 13 | 14 | return ( 15 |
16 | 17 | Height: {height} 18 |
19 | ) 20 | } 21 | 22 | function ChildWithRef ({ ref }: { ref: RefObject }) { 23 | return Child with ref 24 | } 25 | 26 | const App: FunctionComponent = function () { 27 | return ( 28 | 29 | 30 |

Default Stack

31 | 32 | Item 1 33 | Item 2 34 | 35 | Box child 36 | 37 | Nested box child 38 | 39 | 40 | 41 |
42 |

Default Row

43 | 44 | Item 1 45 | Item 2 46 | Item 3 47 | 48 |

Row `gap=0`

49 | 50 | Item 1 51 | Item 2 52 | Item 3 53 | 54 |

Row with expandChildren

55 | 56 | Item 1 57 | Item 2 58 | Item 3 59 | 60 |

Row with xAlign='space-between'

61 | 62 | Item 1 63 | Item 2 64 | Item 3 65 | 66 |

Row with Split

67 | 68 | Item 1 69 | 70 | Item 3 71 | 72 |
73 |

Default Clamp

74 | 75 | Child #1 76 | Child #2 77 | 78 |

Clamp with xAlign='center'

79 | 80 | Child #1 81 | Child #2 82 | 83 |

Clamp with Breakout

84 | 85 | Child #1 86 | 87 | Child #2 (Breakout) 88 | 89 | Child #3 90 | 91 |

Clamp with horizontal and vertical clamping

92 | 93 | Child #1 94 | 95 |

Clamp with only vertical clamping

96 | 97 | Child #1 98 | 99 |

Clamp with yAlign

100 | 101 | Child #1 102 | Child #2 103 | 104 |
105 |

Default Columns

106 | 107 | Item 1 108 | Item 2 109 | Item 3 110 | 111 |

Columns with custom ratio

112 | 113 | Item 1 114 | Item 2 115 | Item 3 116 | 117 |
118 |

Default Grid

119 | 120 | Item 1 121 | Item 2 122 | Item 3 123 | Item 1 124 | Item 2 125 | Item 3 126 | 127 | 128 | 129 |
130 |
131 | ) 132 | } 133 | 134 | // @ts-expect-error 135 | const root = createRoot(document.getElementById('root')) 136 | 137 | root.render( 138 | 139 | 140 | 141 | ) 142 | -------------------------------------------------------------------------------- /src/useRatio.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse ration string in the format `${number}/${number}`. Useful for flexGrow values. 3 | * 4 | * @param ratioString The ratio value 5 | * @returns An array with the parsed ratios 6 | */ 7 | export function useRatio (ratioString: string | boolean = false) { 8 | return parseRatioString(ratioString) 9 | } 10 | 11 | function parseRatioString (ratioString: string | boolean): string | string[] | undefined { 12 | if (ratioString === false) return undefined 13 | if (ratioString === true) return '1' 14 | return ratioString?.split('/') 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/toSelectorString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a class name string as used in HTML's `class` attribute (separated by a space " ") to the CSS dot-notation, so that it can be used as a selector with other libraries like vanilla extract. 3 | * 4 | * E.g. transforms "my-class-1 my-class-2" to ".my-class-1.my-class-2" 5 | * 6 | * @param htmlClass The class names as applied to an HTML element (e.g. "my-class-1 my-class-2") 7 | * @returns A string which can be used as a CSS selector (e.g. ".my-class-1.my-class-2") 8 | */ 9 | export function toSelectorString (htmlClass: string): string { 10 | return `.${htmlClass.split(' ').join('.')}` 11 | } 12 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@lib/*": ["src/*"] 6 | }, 7 | "target": "ESNext", 8 | "useDefineForClassFields": true, 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "allowJs": false, 11 | "skipLibCheck": false, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": ["./src"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | import path from 'path' 6 | import dts from 'vite-plugin-dts' 7 | import { libInjectCss } from 'vite-plugin-lib-inject-css' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | tsconfigPaths({ root: '.' }), 13 | vanillaExtractPlugin(), 14 | libInjectCss(), 15 | react(), 16 | dts(), // Generate TypeScript types 17 | ], 18 | resolve: { 19 | alias: { 20 | // Wihtout this alias, TS path aliases (e.g. "@lib/" are not resolved) 21 | '@lib': path.resolve(__dirname, 'src') 22 | } 23 | }, 24 | build: { 25 | lib: { 26 | entry: path.resolve(__dirname, 'src/index.ts'), 27 | formats: ['es'], 28 | }, 29 | rollupOptions: { 30 | // make sure to externalize deps that shouldn't be bundled 31 | // into your library 32 | external: ['react', 'react-dom', 'react/jsx-runtime'], 33 | } 34 | } 35 | }) 36 | --------------------------------------------------------------------------------