Created
October 2, 2025 20:12
-
-
Save kirkegaard/42be866b8aa5e571d1958c9c0bbc7ed6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Container styles */ | |
| .container { | |
| display: flex; | |
| flex-wrap: var(--wrap, wrap); | |
| flex-direction: var(--direction, row); | |
| width: 100%; | |
| margin: calc(var(--spacing, 0px) / -2); | |
| box-sizing: border-box; | |
| } | |
| /* Item styles */ | |
| .item { | |
| padding: calc(var(--spacing, 0px) / 2); | |
| box-sizing: border-box; | |
| flex-basis: var(--size, auto); | |
| flex-grow: 0; | |
| flex-shrink: 1; | |
| max-width: 100%; | |
| } | |
| /* When size is auto */ | |
| .item[style*="--size: auto"] { | |
| flex-basis: auto; | |
| flex-grow: 0; | |
| flex-shrink: 0; | |
| } | |
| /* When size is 100% (full width) */ | |
| .item[style*="--size: 100%"] { | |
| flex-basis: 100%; | |
| flex-grow: 0; | |
| max-width: 100%; | |
| } | |
| /* Responsive breakpoints */ | |
| @media (min-width: 600px) { | |
| .container { | |
| margin: calc(var(--spacing-sm, var(--spacing, 0px)) / -2); | |
| } | |
| .item { | |
| padding: calc(var(--spacing-sm, var(--spacing, 0px)) / 2); | |
| flex-basis: var(--size-sm, var(--size, auto)); | |
| } | |
| .item[style*="--size-sm: auto"] { | |
| flex-basis: auto; | |
| flex-grow: 0; | |
| flex-shrink: 0; | |
| } | |
| } | |
| @media (min-width: 900px) { | |
| .container { | |
| margin: calc( | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) / -2 | |
| ); | |
| } | |
| .item { | |
| padding: calc( | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) / 2 | |
| ); | |
| flex-basis: var(--size-md, var(--size-sm, var(--size, auto))); | |
| } | |
| .item[style*="--size-md: auto"] { | |
| flex-basis: auto; | |
| flex-grow: 0; | |
| flex-shrink: 0; | |
| } | |
| } | |
| @media (min-width: 1200px) { | |
| .container { | |
| margin: calc( | |
| var( | |
| --spacing-lg, | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) | |
| ) / | |
| -2 | |
| ); | |
| } | |
| .item { | |
| padding: calc( | |
| var( | |
| --spacing-lg, | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) | |
| ) / | |
| 2 | |
| ); | |
| flex-basis: var( | |
| --size-lg, | |
| var(--size-md, var(--size-sm, var(--size, auto))) | |
| ); | |
| } | |
| .item[style*="--size-lg: auto"] { | |
| flex-basis: auto; | |
| flex-grow: 0; | |
| flex-shrink: 0; | |
| } | |
| } | |
| @media (min-width: 1536px) { | |
| .container { | |
| margin: calc( | |
| var( | |
| --spacing-xl, | |
| var( | |
| --spacing-lg, | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) | |
| ) | |
| ) / | |
| -2 | |
| ); | |
| } | |
| .item { | |
| padding: calc( | |
| var( | |
| --spacing-xl, | |
| var( | |
| --spacing-lg, | |
| var(--spacing-md, var(--spacing-sm, var(--spacing, 0px))) | |
| ) | |
| ) / | |
| 2 | |
| ); | |
| flex-basis: var( | |
| --size-xl, | |
| var(--size-lg, var(--size-md, var(--size-sm, var(--size, auto)))) | |
| ); | |
| } | |
| .item[style*="--size-xl: auto"] { | |
| flex-basis: auto; | |
| flex-grow: 0; | |
| flex-shrink: 0; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { forwardRef } from "react"; | |
| import clsx from "clsx"; | |
| import styles from "./Grid.module.css"; | |
| type BreakpointValues<T> = { | |
| xs?: T; | |
| sm?: T; | |
| md?: T; | |
| lg?: T; | |
| xl?: T; | |
| }; | |
| type ResponsiveValue<T> = T | BreakpointValues<T>; | |
| interface GridProps { | |
| children?: React.ReactNode; | |
| className?: string; | |
| container?: boolean; | |
| size?: ResponsiveValue<number | boolean | "auto">; | |
| spacing?: ResponsiveValue<number>; | |
| columns?: ResponsiveValue<number>; | |
| direction?: ResponsiveValue< | |
| "row" | "row-reverse" | "column" | "column-reverse" | |
| >; | |
| wrap?: "nowrap" | "wrap" | "wrap-reverse"; | |
| columnSpacing?: ResponsiveValue<number>; | |
| rowSpacing?: ResponsiveValue<number>; | |
| offset?: ResponsiveValue<number>; | |
| } | |
| const breakpointKeys = ["xs", "sm", "md", "lg", "xl"] as const; | |
| function isResponsiveValue<T>( | |
| value: T | BreakpointValues<T> | |
| ): value is BreakpointValues<T> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| function generateResponsiveStyles<T>( | |
| value: ResponsiveValue<T>, | |
| property: string | |
| ): Record<string, T> { | |
| if (!isResponsiveValue(value)) { | |
| return { [property]: value }; | |
| } | |
| const styles: Record<string, T> = {}; | |
| breakpointKeys.forEach((breakpoint) => { | |
| if (value[breakpoint] !== undefined) { | |
| styles[`${property}-${breakpoint}`] = value[breakpoint]; | |
| } | |
| }); | |
| return styles; | |
| } | |
| function calculateFlexBasis( | |
| size: number | boolean | "auto", | |
| columns: number | |
| ): string { | |
| if (size === "auto") return "auto"; | |
| if (size === true) return "100%"; | |
| if (typeof size === "number" && typeof columns === "number") { | |
| return `${(size / columns) * 100}%`; | |
| } | |
| return "100%"; | |
| } | |
| export const Grid = forwardRef<HTMLDivElement, GridProps>( | |
| ( | |
| { | |
| children, | |
| className, | |
| container = false, | |
| size, | |
| spacing = 0, | |
| columns = 12, | |
| direction = "row", | |
| wrap = "wrap", | |
| columnSpacing, | |
| rowSpacing, | |
| ...props | |
| }, | |
| ref | |
| ) => { | |
| const isContainer = container; | |
| const isItem = !isContainer && size !== undefined; | |
| const baseClass = isContainer ? styles.container : styles.item; | |
| const computedStyles: Record<string, string | number> = {}; | |
| if (isContainer) { | |
| // Handle container spacing | |
| const finalSpacing = columnSpacing ?? rowSpacing ?? spacing; | |
| if (isResponsiveValue(finalSpacing)) { | |
| const spacingStyles = generateResponsiveStyles( | |
| finalSpacing, | |
| "--spacing" | |
| ); | |
| Object.keys(spacingStyles).forEach((key) => { | |
| computedStyles[key] = `${(spacingStyles[key] as number) * 8}px`; | |
| }); | |
| } else { | |
| computedStyles["--spacing"] = `${(finalSpacing as number) * 8}px`; | |
| } | |
| // Handle direction | |
| if (isResponsiveValue(direction)) { | |
| const directionStyles = generateResponsiveStyles( | |
| direction, | |
| "--direction" | |
| ); | |
| Object.assign(computedStyles, directionStyles); | |
| } else { | |
| computedStyles["--direction"] = direction; | |
| } | |
| // Handle wrap | |
| computedStyles["--wrap"] = wrap; | |
| } | |
| if (isItem && size !== undefined) { | |
| // Special handling when size is the same object reference as columns | |
| // This means the item should take full width at each breakpoint | |
| if (size === columns) { | |
| computedStyles["--size"] = "100%"; | |
| breakpointKeys.forEach((breakpoint) => { | |
| computedStyles[`--size-${breakpoint}`] = "100%"; | |
| }); | |
| } else if (isResponsiveValue(size)) { | |
| breakpointKeys.forEach((breakpoint) => { | |
| if (size[breakpoint] !== undefined) { | |
| const breakpointColumns = isResponsiveValue(columns) | |
| ? columns[breakpoint] || 12 | |
| : (columns as number); | |
| const flexBasis = calculateFlexBasis( | |
| size[breakpoint], | |
| breakpointColumns | |
| ); | |
| computedStyles[`--size-${breakpoint}`] = flexBasis; | |
| } | |
| }); | |
| } else { | |
| const currentColumns = isResponsiveValue(columns) | |
| ? 12 | |
| : (columns as number); | |
| const flexBasis = calculateFlexBasis(size, currentColumns); | |
| computedStyles["--size"] = flexBasis; | |
| } | |
| } | |
| return ( | |
| <div | |
| ref={ref} | |
| className={clsx(baseClass, className)} | |
| style={computedStyles} | |
| {...props} | |
| > | |
| {children} | |
| </div> | |
| ); | |
| } | |
| ); | |
| Grid.displayName = "Grid"; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment