A simple tool to build complex CSS Grid templates.
CSS Grid support: http://caniuse.com/#feat=css-grid
A Pen by Anthony Dugois on CodePen.
A simple tool to build complex CSS Grid templates.
CSS Grid support: http://caniuse.com/#feat=css-grid
A Pen by Anthony Dugois on CodePen.
| <div id="root"></div> |
| const {render} = ReactDOM; | |
| const {Component, PropTypes} = React; | |
| const {h1, h2, div, input, textarea, a, svg, g, line, text} = styled.default; | |
| const {darken, lighten, transparentize} = polished; | |
| const {grid, template} = GridTemplateParser; | |
| // helpers | |
| const clamp = (value, min, max) => Math.min(Math.max(value, min), max); | |
| // styles | |
| const colors = { | |
| primary: '#263238', | |
| secondary: '#1DE9B6', | |
| }; | |
| const StyledApp = div` | |
| display: grid; | |
| grid-template-columns: 25rem auto; | |
| grid-template-rows: auto; | |
| grid-template-areas: "sidebar main"; | |
| width: 100%; | |
| height: 100vh; | |
| `; | |
| const StyledSidebar = div` | |
| display: flex; | |
| flex-direction: column; | |
| grid-area: sidebar; | |
| background: ${darken(.1, colors.primary)}; | |
| overflow: hidden; | |
| `; | |
| const StyledMain = div` | |
| display: flex; | |
| flex-direction: column; | |
| grid-area: main; | |
| padding: 2rem; | |
| background: ${darken(.05, colors.primary)}; | |
| `; | |
| const StyledMainInner = div` | |
| flex: 1; | |
| position: relative; | |
| `; | |
| const StyledGrid = svg` | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| `; | |
| const StyledGridText = text` | |
| font-family: 'Roboto Mono', monospace; | |
| font-weight: 500; | |
| font-size: 1rem; | |
| text-anchor: middle; | |
| alignment-baseline: middle; | |
| fill: ${transparentize(.75, colors.secondary)}; | |
| `; | |
| const StyledGridLine = line` | |
| stroke: ${darken(.01, colors.primary)}; | |
| stroke-width: 1px; | |
| `; | |
| const StyledPreview = div` | |
| z-index: 5; | |
| position: relative; | |
| display: grid; | |
| grid-template-columns: repeat(${props => props.width}, 1fr); | |
| grid-template-rows: repeat(${props => props.height}, 1fr); | |
| grid-template-areas: ${props => props.tpl}; | |
| width: 100%; | |
| height: 100%; | |
| `; | |
| const StyledTrack = div` | |
| position: relative; | |
| grid-area: ${props => props.area}; | |
| cursor: ${props => | |
| props.grabbing ? 'grabbing' : 'grab'}; | |
| background: ${transparentize(.97, colors.secondary)}; | |
| `; | |
| const StyledHandler = div` | |
| position: absolute; | |
| top: ${({position}) => | |
| position === 'bottom' ? 'auto' : 0}; | |
| right: ${({position}) => | |
| position === 'left' ? 'auto' : 0}; | |
| bottom: ${({position}) => | |
| position === 'top' ? 'auto' : 0}; | |
| left: ${({position}) => | |
| position === 'right' ? 'auto' : 0}; | |
| width: ${({position, size}) => | |
| position === 'left' || position === 'right' ? size : '100%'}; | |
| height: ${({position, size}) => | |
| position === 'top' || position === 'bottom' ? size : '100%'}; | |
| cursor: ${({position}) => | |
| position === 'left' || position === 'right' ? 'col-resize' : 'row-resize'}; | |
| background: ${colors.secondary}; | |
| `; | |
| const StyledHint = div` | |
| padding: 2rem; | |
| `; | |
| const StyledHintTitle = h1` | |
| padding-bottom: 1rem; | |
| font-weight: 500; | |
| font-size: 1.5rem; | |
| color: ${colors.secondary}; | |
| `; | |
| const StyledHintDescription = div` | |
| line-height: 1.6; | |
| font-size: 1rem; | |
| color: ${lighten(.6, colors.primary)}; | |
| `; | |
| const StyledTemplate = div` | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 2rem; | |
| `; | |
| const StyledTemplateTitle = h2` | |
| padding-bottom: 1.5rem; | |
| text-transform: uppercase; | |
| font-size: .85rem; | |
| font-weight: 500; | |
| color: ${colors.secondary}; | |
| letter-spacing: .1rem; | |
| `; | |
| const StyledTemplateControl = div` | |
| flex: 1; | |
| `; | |
| const StyledTemplateInput = textarea` | |
| width: 100%; | |
| height: 100%; | |
| padding: 2rem; | |
| background: ${darken(.125, colors.primary)}; | |
| border-radius: 2px; | |
| border: none; | |
| resize: none; | |
| line-height: 1.5; | |
| font-family: 'Roboto Mono', monospace; | |
| font-size: .85rem; | |
| color: #fff; | |
| transition: background .2s; | |
| &:focus { | |
| outline: 0; | |
| background: ${darken(.15, colors.primary)}; | |
| } | |
| `; | |
| const StyledSettings = div` | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding-bottom: 2rem; | |
| &::before, | |
| &::after { | |
| content: ''; | |
| flex: 1; | |
| display: block; | |
| height: 1px; | |
| background: ${colors.primary}; | |
| } | |
| `; | |
| const StyledSettingDivider = div` | |
| text-align: center; | |
| font-family: 'Roboto Mono'; | |
| font-weight: 500; | |
| font-size: 1.05rem; | |
| color: ${lighten(.05, colors.primary)}; | |
| `; | |
| const StyledSettingInput = input` | |
| width: 4rem; | |
| padding: .4rem .6rem; | |
| margin: 0 .75rem; | |
| background: ${darken(.1, colors.primary)}; | |
| border: none; | |
| border-radius: 2px; | |
| text-align: center; | |
| font-family: 'Roboto Mono'; | |
| font-size: .8rem; | |
| color: #fff; | |
| transition: background .2s; | |
| &:focus { | |
| outline: 0; | |
| background: ${darken(.125, colors.primary)}; | |
| } | |
| `; | |
| const StyledFoot = div` | |
| padding: 0 2rem 2rem; | |
| `; | |
| const StyledLink = a` | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| text-decoration: none; | |
| font-weight: 500; | |
| font-size: .8rem; | |
| color: ${colors.secondary}; | |
| transition: color .2s; | |
| &:hover { | |
| color: #fff; | |
| } | |
| &::before, | |
| &::after { | |
| content: ''; | |
| flex: 1; | |
| display: block; | |
| height: 1px; | |
| background: ${colors.primary}; | |
| } | |
| &::before { | |
| margin-right: .75rem; | |
| } | |
| &::after { | |
| margin-left: .75rem; | |
| } | |
| `; | |
| function Sidebar(props) { | |
| return ( | |
| <StyledSidebar> | |
| <Hint /> | |
| <Template {...props} /> | |
| <Foot /> | |
| </StyledSidebar> | |
| ); | |
| } | |
| function Hint() { | |
| return ( | |
| <StyledHint> | |
| <StyledHintTitle> | |
| CSS Grid Template Builder | |
| </StyledHintTitle> | |
| <StyledHintDescription> | |
| A simple tool to build complex CSS Grid templates. | |
| Edit the template string below or drag the areas in the preview. | |
| The changes will reflect in both sides. | |
| </StyledHintDescription> | |
| </StyledHint> | |
| ); | |
| } | |
| function Foot() { | |
| return ( | |
| <StyledFoot> | |
| <StyledLink href="https://twitter.com/a_dugois" target="_blank"> | |
| Follow me on Twitter! | |
| </StyledLink> | |
| </StyledFoot> | |
| ); | |
| } | |
| function Template({tpl, setTracks}) { | |
| return ( | |
| <StyledTemplate> | |
| <StyledTemplateTitle> | |
| Template areas | |
| </StyledTemplateTitle> | |
| <StyledTemplateControl> | |
| <Text value={tpl} onBlur={setTracks}> | |
| {props => <StyledTemplateInput {...props} />} | |
| </Text> | |
| </StyledTemplateControl> | |
| </StyledTemplate> | |
| ); | |
| } | |
| function Main({tpl, width, height, areas, setArea, setWidth, setHeight}) { | |
| return ( | |
| <StyledMain> | |
| <Settings | |
| width={width} | |
| height={height} | |
| setWidth={setWidth} | |
| setHeight={setHeight} /> | |
| <StyledMainInner> | |
| <Grid | |
| width={width} | |
| height={height} | |
| areas={areas} /> | |
| <Preview | |
| tpl={tpl} | |
| width={width} | |
| height={height} | |
| areas={areas} | |
| setArea={setArea} /> | |
| </StyledMainInner> | |
| </StyledMain> | |
| ); | |
| } | |
| function Settings({width, height, setWidth, setHeight}) { | |
| return ( | |
| <StyledSettings> | |
| <Text value={width} onBlur={setWidth}> | |
| {props => <StyledSettingInput {...props} />} | |
| </Text> | |
| <StyledSettingDivider>x</StyledSettingDivider> | |
| <Text value={height} onBlur={setHeight}> | |
| {props => <StyledSettingInput {...props} />} | |
| </Text> | |
| </StyledSettings> | |
| ); | |
| } | |
| function Track({area, column, row, grabbing, onMouseDown, onHandlerMouseDown}) { | |
| return ( | |
| <StyledTrack | |
| area={area} | |
| grabbing={grabbing} | |
| onMouseDown={onMouseDown}> | |
| <Handler position="top" onMouseDown={onHandlerMouseDown('top')} /> | |
| <Handler position="right" onMouseDown={onHandlerMouseDown('right')} /> | |
| <Handler position="bottom" onMouseDown={onHandlerMouseDown('bottom')} /> | |
| <Handler position="left" onMouseDown={onHandlerMouseDown('left')} /> | |
| </StyledTrack> | |
| ); | |
| } | |
| function Handler({position, onMouseDown}) { | |
| return ( | |
| <StyledHandler | |
| size="6px" | |
| position={position} | |
| onMouseDown={onMouseDown} /> | |
| ); | |
| } | |
| class App extends Component { | |
| state = { | |
| tracks: { | |
| width: 4, | |
| height: 6, | |
| areas: { | |
| head: { | |
| column: { start: 1, end: 5, span: 4 }, | |
| row: { start: 1, end: 2, span: 1 }, | |
| }, | |
| aside: { | |
| column: { start: 1, end: 2, span: 1 }, | |
| row: { start: 2, end: 4, span: 2 }, | |
| }, | |
| main: { | |
| column: { start: 2, end: 5, span: 3 }, | |
| row: { start: 2, end: 6, span: 4 }, | |
| }, | |
| foot: { | |
| column: { start: 1, end: 5, span: 4 }, | |
| row: { start: 6, end: 7, span: 1 }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| setTracks = evt => { | |
| this.setState(() => ({tracks: grid(evt.target.value)})); | |
| }; | |
| integer = (value, previous, min, max) => { | |
| const int = parseInt(value); | |
| const safe = isNaN(int) ? previous : clamp(int, min, max); | |
| return safe; | |
| }; | |
| setWidth = evt => { | |
| this.setState(({tracks}) => ({ | |
| tracks: { | |
| ...tracks, | |
| width: this.integer(evt.target.value, tracks.width, 1, 100), | |
| }, | |
| })); | |
| }; | |
| setHeight = evt => { | |
| this.setState(({tracks}) => ({ | |
| tracks: { | |
| ...tracks, | |
| height: this.integer(evt.target.value, tracks.height, 1, 100), | |
| }, | |
| })); | |
| }; | |
| setArea = (key, value) => { | |
| this.setState(({tracks}) => ({ | |
| tracks: { | |
| ...tracks, | |
| areas: { | |
| ...tracks.areas, | |
| [key]: value, | |
| }, | |
| }, | |
| })); | |
| }; | |
| render() { | |
| const {tracks} = this.state; | |
| const {width, height, areas} = tracks; | |
| const tpl = template(tracks); | |
| return ( | |
| <StyledApp> | |
| <Sidebar | |
| tpl={tpl} | |
| setTracks={this.setTracks} /> | |
| <Main | |
| tpl={tpl} | |
| width={width} | |
| height={height} | |
| areas={areas} | |
| setArea={this.setArea} | |
| setWidth={this.setWidth} | |
| setHeight={this.setHeight} /> | |
| </StyledApp> | |
| ); | |
| } | |
| } | |
| class Text extends Component { | |
| static defaultProps = { | |
| value: '', | |
| onFocus: () => {}, | |
| onBlur: () => {}, | |
| onChange: () => {}, | |
| }; | |
| state = { | |
| isFocused: false, | |
| value: this.props.value, | |
| }; | |
| componentWillReceiveProps({value}) { | |
| this.setState(() => ({value})); | |
| } | |
| handleFocus = evt => { | |
| evt.persist(); | |
| this.props.onFocus(evt); | |
| this.setState(() => ({isFocused: true})); | |
| }; | |
| handleBlur = evt => { | |
| evt.persist(); | |
| this.props.onBlur(evt); | |
| this.setState(() => ({isFocused: false})); | |
| }; | |
| handleChange = evt => { | |
| evt.persist(); | |
| const {value} = evt.target; | |
| this.props.onChange(evt); | |
| this.setState(() => ({value})); | |
| }; | |
| render() { | |
| const {isFocused} = this.state; | |
| const value = isFocused ? this.state.value : this.props.value; | |
| return this.props.children({ | |
| value, | |
| onFocus: this.handleFocus, | |
| onBlur: this.handleBlur, | |
| onChange: this.handleChange, | |
| }); | |
| } | |
| } | |
| class Preview extends Component { | |
| constructor() { | |
| super(); | |
| this.dx = 0; | |
| this.dy = 0; | |
| } | |
| state = { | |
| isDragging: false, | |
| draggedArea: null, | |
| draggedPosition: null, | |
| }; | |
| componentDidMount() { | |
| document.addEventListener('mouseup', this.handleMouseUp); | |
| document.addEventListener('mousemove', this.handleMouseMove); | |
| } | |
| componentWillUnmount() { | |
| document.removeEventListener('mouseup', this.handleMouseUp); | |
| document.removeEventListener('mousemove', this.handleMouseMove); | |
| } | |
| handleMouseUp = evt => { | |
| if (this.state.isDragging) { | |
| this.setState(() => ({ | |
| isDragging: false, | |
| draggedArea: null, | |
| draggedPosition: null, | |
| })); | |
| } | |
| }; | |
| handleMouseMove = evt => { | |
| const {width, height} = this.props; | |
| const {isDragging, draggedArea, draggedPosition} = this.state; | |
| if (isDragging) { | |
| const rect = this.node.getBoundingClientRect(); | |
| const x = Math.round((evt.clientX - rect.left) / rect.width * width); | |
| const y = Math.round((evt.clientY - rect.top) / rect.height * height); | |
| switch (true) { | |
| case typeof draggedPosition === 'string': | |
| return this.moveHandler(x, y); | |
| case typeof draggedArea === 'string': | |
| return this.moveTrack(x, y); | |
| } | |
| } | |
| }; | |
| makeTrackMouseDown = draggedArea => evt => { | |
| evt.preventDefault(); | |
| const {width, height, areas} = this.props; | |
| const area = areas[draggedArea]; | |
| const rect = this.node.getBoundingClientRect(); | |
| const x = Math.round((evt.clientX - rect.left) / rect.width * width); | |
| const y = Math.round((evt.clientY - rect.top) / rect.height * height); | |
| this.dx = x - area.column.start + 1; | |
| this.dy = y - area.row.start + 1; | |
| this.setState(() => ({isDragging: true, draggedArea})); | |
| }; | |
| makeHandlerMouseDown = draggedArea => draggedPosition => evt => { | |
| evt.preventDefault(); | |
| this.setState(() => ({isDragging: true, draggedArea, draggedPosition})); | |
| }; | |
| moveTrack = (x, y) => { | |
| const {width, height, areas, setArea} = this.props; | |
| const {draggedArea} = this.state; | |
| const area = areas[draggedArea]; | |
| const top = this.findAdjacentArea('top', draggedArea); | |
| const right = this.findAdjacentArea('right', draggedArea); | |
| const bottom = this.findAdjacentArea('bottom', draggedArea); | |
| const left = this.findAdjacentArea('left', draggedArea); | |
| const columnStart = clamp( | |
| x - this.dx + 1, | |
| typeof left === 'string' ? areas[left].column.end : 1, | |
| (typeof right === 'string' ? areas[right].column.start : width + 1) - area.column.span, | |
| ); | |
| const rowStart = clamp( | |
| y - this.dy + 1, | |
| typeof top === 'string' ? areas[top].row.end : 1, | |
| (typeof bottom === 'string' ? areas[bottom].row.start : height + 1) - area.row.span, | |
| ); | |
| if (columnStart !== area.column.start || rowStart !== area.row.start) { | |
| const columnEnd = columnStart + area.column.span; | |
| const rowEnd = rowStart + area.row.span; | |
| return setArea(draggedArea, { | |
| column: { | |
| ...area.column, | |
| start: columnStart, | |
| end: columnEnd, | |
| }, | |
| row: { | |
| ...area.row, | |
| start: rowStart, | |
| end: rowEnd, | |
| }, | |
| }); | |
| } | |
| }; | |
| moveHandler = (x, y) => { | |
| const {width, height, areas, setArea} = this.props; | |
| const {draggedPosition, draggedArea} = this.state; | |
| const area = areas[draggedArea]; | |
| const adj = this.findAdjacentArea(draggedPosition, draggedArea); | |
| if (draggedPosition === 'top') { | |
| const start = clamp( | |
| y + 1, | |
| typeof adj === 'string' ? areas[adj].row.end : 1, | |
| area.row.end - 1, | |
| ); | |
| return setArea(draggedArea, { | |
| ...area, | |
| row: { | |
| ...area.row, | |
| span: area.row.end - start, | |
| start, | |
| }, | |
| }); | |
| } | |
| if (draggedPosition === 'right') { | |
| const end = clamp( | |
| x + 1, | |
| area.column.start + 1, | |
| typeof adj === 'string' ? areas[adj].column.start : width + 1, | |
| ); | |
| return setArea(draggedArea, { | |
| ...area, | |
| column: { | |
| ...area.column, | |
| span: end - area.column.start, | |
| end, | |
| }, | |
| }); | |
| } | |
| if (draggedPosition === 'bottom') { | |
| const end = clamp( | |
| y + 1, | |
| area.row.start + 1, | |
| typeof adj === 'string' ? areas[adj].row.start : height + 1, | |
| ); | |
| return setArea(draggedArea, { | |
| ...area, | |
| row: { | |
| ...area.row, | |
| span: end - area.row.start, | |
| end, | |
| }, | |
| }); | |
| } | |
| if (draggedPosition === 'left') { | |
| const start = clamp( | |
| x + 1, | |
| typeof adj === 'string' ? areas[adj].column.end : 1, | |
| area.column.end - 1, | |
| ); | |
| return setArea(draggedArea, { | |
| ...area, | |
| column: { | |
| ...area.column, | |
| span: area.column.end - start, | |
| start, | |
| }, | |
| }); | |
| } | |
| }; | |
| findAdjacentArea = (direction, area) => { | |
| const {areas} = this.props; | |
| const {column, row} = areas[area]; | |
| const keys = Object.keys(areas); | |
| if (direction === 'top') { | |
| return keys.find(key => | |
| areas[key].row.end === row.start && | |
| areas[key].column.start < column.end && | |
| areas[key].column.end > column.start | |
| ); | |
| } | |
| if (direction === 'right') { | |
| return keys.find(key => | |
| areas[key].column.start === column.end && | |
| areas[key].row.start < row.end && | |
| areas[key].row.end > row.start | |
| ); | |
| } | |
| if (direction === 'bottom') { | |
| return keys.find(key => | |
| areas[key].row.start === row.end && | |
| areas[key].column.start < column.end && | |
| areas[key].column.end > column.start | |
| ); | |
| } | |
| if (direction === 'left') { | |
| return keys.find(key => | |
| areas[key].column.end === column.start && | |
| areas[key].row.start < row.end && | |
| areas[key].row.end > row.start | |
| ); | |
| } | |
| }; | |
| render() { | |
| const {tpl, width, height, areas} = this.props; | |
| const {isDragging, draggedArea, draggedPosition} = this.state; | |
| return ( | |
| <StyledPreview | |
| innerRef={node => this.node = node} | |
| tpl={tpl} | |
| width={width} | |
| height={height}> | |
| {Object.keys(areas).map(area => ( | |
| <Track | |
| key={area} | |
| area={area} | |
| column={areas[area].column} | |
| row={areas[area].row} | |
| grabbing={isDragging && draggedArea === area && typeof draggedPosition !== 'string'} | |
| onMouseDown={this.makeTrackMouseDown(area)} | |
| onHandlerMouseDown={this.makeHandlerMouseDown(area)} /> | |
| ))} | |
| </StyledPreview> | |
| ); | |
| } | |
| } | |
| class Grid extends Component { | |
| renderArea = area => { | |
| const {width, height, areas} = this.props; | |
| const {row, column} = areas[area]; | |
| return Array.from( | |
| {length: row.span}, | |
| (_, r) => Array.from( | |
| {length: column.span}, | |
| (_, c) => ( | |
| <StyledGridText | |
| key={`area${r}${c}`} | |
| x={`${(column.start + c - .5) / width * 100}%`} | |
| y={`${(row.start + r - .5) / height * 100}%`}> | |
| {area} | |
| </StyledGridText> | |
| ), | |
| ), | |
| ); | |
| }; | |
| renderCols = (_, index) => { | |
| const {width} = this.props; | |
| return ( | |
| <StyledGridLine | |
| key={index} | |
| x1={`${(index + 1) / width * 100}%`} | |
| y1="0%" | |
| x2={`${(index + 1) / width * 100}%`} | |
| y2="100%" /> | |
| ); | |
| }; | |
| renderRows = (_, index) => { | |
| const {height} = this.props; | |
| return ( | |
| <StyledGridLine | |
| key={index} | |
| x1="0%" | |
| y1={`${(index + 1) / height * 100}%`} | |
| x2="100%" | |
| y2={`${(index + 1) / height * 100}%`} /> | |
| ); | |
| }; | |
| render() { | |
| const { | |
| width, | |
| height, | |
| areas, | |
| } = this.props; | |
| return ( | |
| <StyledGrid> | |
| <g>{Object.keys(areas).map(this.renderArea)}</g> | |
| <g>{Array.from({length: width - 1}, this.renderCols)}</g> | |
| <g>{Array.from({length: height - 1}, this.renderRows)}</g> | |
| </StyledGrid> | |
| ); | |
| } | |
| } | |
| render( | |
| <App />, | |
| document.querySelector('#root'), | |
| ); |
| <script src="https://unpkg.com/react@15.4.2/dist/react.min.js"></script> | |
| <script src="https://unpkg.com/react-dom@15.4.2/dist/react-dom.min.js"></script> | |
| <script src="https://unpkg.com/styled-components@1.4.4/dist/styled-components.min.js"></script> | |
| <script src="https://unpkg.com/polished@1.0.0/dist/polished.min.js"></script> | |
| <script src="https://unpkg.com/grid-template-parser@0.3.2/dist/grid-template-parser.min.js"></script> |
| @import url('https://fonts.googleapis.com/css?family=Roboto:400,500|Roboto+Mono:400,500'); | |
| *, | |
| *::after, | |
| *::before { | |
| box-sizing: inherit; | |
| } | |
| html { | |
| box-sizing: border-box; | |
| font-size: 16px; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background: #fff; | |
| line-height: 1; | |
| font-family: 'Roboto', sans-serif; | |
| font-weight: 400; | |
| font-size: 1rem; | |
| color: #000; | |
| } |