☰
纯CSS绘制米字格、田字格、回字格、九宫格:可打印书法练习纸实现
2026/10/31 3:05:53 网站建设 项目流程

练字的人对纸的要求其实挺挑剔的。市面上买来的书法练习纸,格子印刷的粗细、颜色深浅、留白比例,往往跟自己的书写习惯对不上——要么格子线太重抢了字的气势,要么米字格的斜线角度看着别扭,要么想练小楷时发现手头只有大号田字格。我之前给一个做硬笔教学的朋友搭过一套在线练字页面,需求很朴素:能不能用纯CSS把米字格、田字格、回字格、九宫格这几种书法专用纸画出来,尺寸、线宽、颜色都能调,打印出来还能直接用。这件事听起来像是Canvas或者SVG的活儿,但实测下来,纯CSS方案在清晰度、可维护性和响应式适配上反而更省心,尤其是打印场景,矢量线条不会糊。

这篇就把这套东西完整拆一遍。核心关键词是米字格、田字格、回字格、九宫格的CSS实现,涉及背景渐变、伪元素、边框叠加、CSS变量这些技术点。适合两类人看:一是做教育类、练字类前端页面的开发者,二是想自己折腾一套可打印练习纸的书法爱好者。哪怕你CSS只会写个居中,跟着走也能跑通,因为每个格子的原理我都会拆到“为什么这么画”的层面。

1. 先想清楚:为什么书法格子适合用CSS画而不是图片

动手之前先把这个决策讲透,不然写到一半容易怀疑人生。书法练习纸的本质是什么?是一组规则重复的几何线条:外框、中线、对角线、内框。这类东西有三个特点——线条是纯几何的、需要精确对齐、经常要按尺寸缩放。这三点恰好是CSS背景渐变和边框的强项,也是位图的弱项。

用图片做格子纸,最直接的问题是缩放糊。一张200×200像素的田字格PNG,放到打印尺寸或者高分屏上,中线会发虚,斜线会有锯齿。你可能想那我导出个大图不就行了,但大图又带来文件体积和内存占用的问题,一页A4纸铺满格子,图片方案要么重复请求同一张图(浏览器缓存能救一部分),要么拼一张巨图。而CSS画的线是浏览器实时渲染的,任何分辨率下都是锐利的,打印时更是直接走矢量路径。

第二个理由是参数化。书法纸的需求差异很大:小学生练字用粗一点的红色田字格,成人练小楷可能要浅灰色的细线米字格,还有人要虚线格。图片方案每换一种规格就得重新做图,CSS方案只需要改几个变量。我朋友那个项目里,格子的线宽、颜色、透明度、内框比例全是CSS变量控制的,前端给个滑块就能实时预览,这个体验是图片方案给不了的。

第三个理由是布局对齐。一页纸铺N×N个格子,格子之间还要留间距,用图片得算好每个img的尺寸和margin,稍有不慎就有半像素的缝隙。CSS方案里,格子本身就是背景,铺排靠background-size和background-repeat,天然无缝,间距也由背景尺寸统一控制。

当然CSS方案也有它的边界,得说清楚:它适合规则格子,不适合任意手绘风格的纹理。如果你要的是那种带纸张纤维质感、边缘毛糙的古风宣纸效果,CSS渐变做不出来,还是得叠一张纹理图。但对于米字格、田字格、回字格、九宫格这四种标准格,CSS是更优解。下面逐个拆。

2. 田字格:一条中线拆成两半,是最容易上手的起点

田字格是四种格里结构最简单的:一个正方形外框,加一条横中线和一条竖中线,把格子分成四个小方块。很多人第一反应是用border画外框,然后里面塞两个绝对定位的div当线。能跑,但格子一多,DOM就爆炸了——一页纸50个格子就是150个元素,维护起来很难受。

更干净的做法是整个格子只用一个元素,所有线条用背景渐变画。核心思路是把“线”理解成“背景色块”:一条1px的竖中线,本质是一个宽度1px、高度100%的色块,用linear-gradient就能生成。

.tian { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(var(--line), var(--line)), linear-gradient(var(--line), var(--line)); background-size: var(--weight) 100%, 100% var(--weight); background-position: center center, center center; background-repeat: no-repeat; }

这段代码的逻辑值得逐行讲。两个linear-gradient(var(--line), var(--line))生成的是纯色块(起止色相同,没有过渡),第一个色块尺寸是“1px宽、100%高”,定位在中心,就是竖中线;第二个是“100%宽、1px高”,定位在中心,就是横中线。background-repeat: no-repeat保证每条线只画一次。外框用border而不是背景,是因为边框会占据盒模型空间,格子内容区自然内缩,后续如果要往格子里放字,不会被线压住。

这里有个容易踩的坑:background-position: center在尺寸为奇数像素时会因为亚像素舍入导致线偏移半像素,看起来有点糊。解决办法是让--size保持偶数,或者线宽用偶数。我在项目里统一把格子尺寸约束成4的倍数,实测下来线条最锐利。

田字格还有个变体需求:内缩的外框。有些练习纸的外框不是贴着格子边缘,而是往里收一点,形成“口”字套“口”字的感觉。这个用outline配合outline-offset能实现,但outline在部分老浏览器上圆角支持不好。更稳的做法是再加一层背景渐变画内框,或者用box-shadow: inset。我倾向后者:

.tian-inset { box-shadow: inset 0 0 0 var(--weight) var(--line); }

inset阴影画出来的内框贴着padding box,配合padding就能控制内缩距离,比outline可控。这个技巧在后面回字格里还会用到,先记住。

3. 米字格:斜线是难点,两个渐变叠出对角线

米字格在田字格基础上多了两条对角线,形成“米”字。横竖中线好办,难的是斜线——CSS背景渐变默认是水平或垂直方向的,画斜线得用角度渐变,而且角度和格子尺寸挂钩,不是固定值。

先看对角线怎么用渐变画。一条从左上到右下的对角线,本质是一个沿45度方向、宽度为线宽的色带。用linear-gradient(45deg, ...)配合色标位置可以做到,但更精确的方式是用repeating-linear-gradient或者直接算角度。我实测下来最稳的方案是用两个linear-gradient分别画两条对角线,角度根据正方形固定为45度和-45度(即135度),因为正方形对角线角度恒为45度,不随尺寸变化,这点很省心。

.mi { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(var(--line), var(--line)), linear-gradient(var(--line), var(--line)), linear-gradient(45deg, transparent calc(50% - var(--weight) / 2), var(--line) calc(50% - var(--weight) / 2), var(--line) calc(50% + var(--weight) / 2), transparent calc(50% + var(--weight) / 2)), linear-gradient(-45deg, transparent calc(50% - var(--weight) / 2), var(--line) calc(50% - var(--weight) / 2), var(--line) calc(50% + var(--weight) / 2), transparent calc(50% + var(--weight) / 2)); background-size: var(--weight) 100%, 100% var(--weight), 100% 100%, 100% 100%; background-position: center; background-repeat: no-repeat; }

斜线那两行的色标计算是重点。calc(50% - var(--weight) / 2)到calc(50% + var(--weight) / 2)这个区间,是在渐变轴上取“中心线两侧各半个线宽”的范围填成实色,其余透明。这样画出来的斜线宽度正好等于--weight,而且居中。为什么用50%而不是固定像素?因为渐变轴的长度随格子尺寸变化,用百分比才能保证线始终在正中间。

这里有个实测出来的细节:45度渐变的“50%”指的是渐变轴上的中点,而渐变轴的长度是格子对角线长度(正方形时是边长×√2),所以斜线在视觉上会略微超出格子四角。要让它精确落在角上,得把渐变尺寸设为100% 100%并配合background-size,让渐变轴正好覆盖整个盒子。上面代码里斜线的background-size是100% 100%,就是这个道理。如果写成别的值,斜线会偏移。

米字格还有一个常见变体是只有斜线没有中线(有些字帖叫“对角线格”),或者斜线用虚线。虚线斜线用渐变实现比较绕,色标要做成交替的实虚段,计算量大且不好维护。我的建议是:虚线需求用repeating-linear-gradient沿45度方向铺,但线宽和虚线间隔要用像素而非百分比,否则缩放时会变形。这个取舍在项目里要提前跟需求方确认,别做到一半才发现要虚线。

4. 回字格:内外双框,box-shadow和渐变的分工

回字格的结构是“口”套“口”:外框、内框,中间留白。它没有中线,但对内外框的比例和间距有要求。很多人第一反应是套两个div,外层border,内层再border。DOM翻倍的问题又来了,而且内框的尺寸要手动算,格子一多就是灾难。

纯CSS方案里,回字格有两种画法,各有适用场景。第一种是box-shadow: inset画内框,前面提过。优点是代码短,内框位置由padding或inset偏移控制,语义清晰。缺点是只能画一个内框,如果要“回”字套“回”字(三重框),就得叠多层shadow,可读性下降。

.hui-shadow { --size: 120px; --line: #d94f4f; --weight: 1px; --gap: 18px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); box-shadow: inset 0 0 0 var(--weight) var(--line); padding: var(--gap); box-sizing: border-box; }

注意这里box-sizing: border-box很关键。不加的话,padding会把盒子撑大,格子尺寸失控。加了之后,--size是含边框和padding的总尺寸,内框自然落在距外框--gap的位置。这个方案里内框是贴着padding box的,所以内框和外框之间的间距就是--gap,直观好调。

第二种是用背景渐变画内框,适合需要精确控制内框线宽、或者内框要用不同颜色/虚线的情况。思路是把内框理解成四条边,用四个渐变分别画上下左右,或者用一个repeating技巧。但四条边分开画代码冗长,我更推荐用两个方向的渐变叠加:一个水平渐变画左右两条竖边,一个垂直渐变画上下两条横边,通过色标位置控制内框的偏移。

.hui-gradient { --size: 120px; --line: #d94f4f; --weight: 1px; --gap: 18px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: linear-gradient(90deg, transparent var(--gap), var(--line) var(--gap), var(--line) calc(var(--gap) + var(--weight)), transparent calc(var(--gap) + var(--weight)), transparent calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap)), transparent calc(100% - var(--gap))), linear-gradient(0deg, transparent var(--gap), var(--line) var(--gap), var(--line) calc(var(--gap) + var(--weight)), transparent calc(var(--gap) + var(--weight)), transparent calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap) - var(--weight)), var(--line) calc(100% - var(--gap)), transparent calc(100% - var(--gap))); background-size: 100% 100%; background-repeat: no-repeat; }

这段色标看着吓人,其实逻辑很直白:从左往右,前--gap透明(外框到内框的留白),接着--weight实色(内框左边),再透明到右边对称位置,然后--weight实色(内框右边),最后--gap透明。垂直方向同理。两个渐变叠起来,就是完整的回字内框。这个写法的好处是内框线宽、间距、颜色都能独立调,而且不依赖padding,格子内容区不受影响。

选型建议:如果只是标准回字格,用box-shadow方案,代码短好维护;如果内框要虚线、要异色、或者要三重框,用渐变方案。我在项目里两种都留了,用CSS变量切换,因为不同字帖的需求真的不一样。

5. 九宫格:3×3分割,repeating渐变一次铺满

九宫格是把正方形均分成3×3的九个小格,需要两条横线和两条竖线。它和前面几种格的区别在于线是等距重复的,这正好是repeating-linear-gradient的用武之地。用单次渐变就能把两条线一次画出来,不用像田字格那样叠两个渐变。

.jiu { --size: 120px; --line: #d94f4f; --weight: 1px; width: var(--size); height: var(--size); border: var(--weight) solid var(--line); background-image: repeating-linear-gradient(90deg, transparent 0, transparent calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% + var(--weight) / 2), transparent calc(33.333% + var(--weight) / 2), transparent calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% + var(--weight) / 2), transparent calc(66.666% + var(--weight) / 2), transparent 100%), repeating-linear-gradient(0deg, transparent 0, transparent calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% - var(--weight) / 2), var(--line) calc(33.333% + var(--weight) / 2), transparent calc(33.333% + var(--weight) / 2), transparent calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% - var(--weight) / 2), var(--line) calc(66.666% + var(--weight) / 2), transparent calc(66.666% + var(--weight) / 2), transparent 100%); background-size: 100% 100%; background-repeat: no-repeat; }

这里用repeating-linear-gradient其实有点“杀鸡用牛刀”,因为九宫格的线是固定两条,不是无限重复。更准确的写法是用普通linear-gradient,色标里写死33.333%和66.666%两个位置。但repeating的好处是如果以后要改成4×4、5×5,只需要改百分比步长,扩展性好。我在项目里封装了一个函数,根据--n(几宫格)动态生成色标,九宫格只是n=3的特例。

九宫格的精度问题值得单独说。33.333%这种无限循环小数,在浏览器里会被舍入,导致两条线的位置不完全对称,肉眼可能看不出来,但放大或者打印时能发现左边格子比右边宽一点点。解决办法有两个:一是用calc(100% / 3)让浏览器自己算,精度更高;二是干脆用像素值,前提是格子尺寸固定。我推荐calc(100% / 3),兼顾精度和响应式。

/* 更精确的九宫格线位置 */ --pos1: calc(100% / 3); --pos2: calc(200% / 3);

另外,九宫格经常和米字格组合使用,形成“九宫米字格”——九个小格每个里面还有对角线。这种复合格用CSS画,就是把九宫格的渐变和米字格的斜线渐变叠在一起,background-image里放四组渐变。代码会长,但逻辑是清晰的加法。实测下来,四组渐变叠加在120px的格子上性能没问题,一页50个格子也不卡。

6. 把格子铺成一页纸:background-size和repeat的配合

单个格子画好了,接下来是铺排。书法练习纸是一页铺满格子,这步用background-repeat配合background-size就能实现,不需要生成一堆DOM元素。这是CSS方案相对图片和div方案最大的优势——一页纸就是一个元素。

.paper { --cell: 120px; --gap: 8px; --line: #d94f4f; --weight: 1px; width: 100%; min-height: 100vh; background-color: #fff; background-image: /* 这里放单个格子的渐变组,但要注意:背景渐变无法直接“每个格子带边框” */ }

这里有个关键限制必须讲清楚:background-image的渐变是铺在整个元素上的,background-size控制的是每个“平铺单元”的尺寸,但边框(border)不能跟着平铺。也就是说,如果你用border画外框,铺排时只有最外层元素有边框,里面的格子没有。这是CSS背景平铺的固有行为,绕不过去。

解决办法是把外框也画进背景渐变里。具体做法是在单个格子的渐变组里,用四个渐变分别画上、下、左、右四条边框线,然后整个渐变组作为background-image,background-size设为calc(var(--cell) + var(--gap)),background-repeat: repeat。这样每个平铺单元都自带完整边框,铺出来就是一张完整的格子纸。

.paper { --cell: 120px; --gap: 8px; --line: #d94f4f; --weight: 1px; --unit: calc(var(--cell) + var(--gap)); background-color: #fff; background-image: /* 上边框 */ linear-gradient(var(--line), var(--line)), /* 下边框 */ linear-gradient(var(--line), var(--line)), /* 左边框 */ linear-gradient(var(--line), var(--line)), /* 右边框 */ linear-gradient(var(--line), var(--line)), /* 横中线 */ linear-gradient(var(--line), var(--line)), /* 竖中线 */ linear-gradient(var(--line), var(--line)); background-size: var(--cell) var(--weight), var(--cell) var(--weight), var(--weight) var(--cell), var(--weight) var(--cell), var(--weight) 100%, 100% var(--weight); background-position: 0 0, 0 100%, 0 0, 100% 0, center center, center center; background-repeat: repeat; }

这段代码里,前四个渐变画边框,后两个画中线,background-size里边框的尺寸是var(--cell) var(--weight)(横边框)和var(--weight) var(--cell)(竖边框),定位分别贴四边。background-repeat: repeat让整个渐变组按--unit平铺。注意中线的background-size写的是var(--weight) 100%和100% var(--weight),这里的100%是相对于平铺单元的高度/宽度,所以中线会贯穿整个单元,包括gap区域——这会导致格子之间的间隙里也有线,看起来像连成一片。

要解决这个问题,中线的尺寸要改成var(--cell)而不是100%,并且定位要配合gap偏移。更干净的做法是把gap做成格子内部的padding,即平铺单元就是格子本身,格子之间不留gap,靠格子内部的留白制造视觉间隔。这样中线尺寸用100%就不会溢出。我在项目里最终采用的是后者:--unit等于--cell,格子之间不留缝,视觉上的“间隔”由格子内部的留白(比如中线不画到边缘)来营造。这个取舍要看具体字帖风格,没有绝对对错。

7. 打印适配:让格子纸真正能打出来用

在线预览好看是一回事,打印出来能用是另一回事。书法练习纸的打印有几个硬性要求:线条要够细够清晰、格子尺寸要符合实际毫米数、颜色要能黑白打印。这几点CSS都能控,但有几个坑得提前踩。

第一个坑是打印时的背景色默认不输出。浏览器为了省墨,打印时默认不打印背景色和背景图。如果你的格子全靠background-image画,打印出来就是白纸。解决办法是加-webkit-print-color-adjust: exact和print-color-adjust: exact,强制输出背景。这个属性在主流浏览器上都支持,但要注意它只对当前元素生效,得加在格子元素上。

@media print { .paper { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }

第二个坑是尺寸单位。屏幕上用px无所谓,但打印时px和实际毫米的换算依赖DPI,不同打印机不一样。要让格子精确到毫米(比如标准田字格是15mm×15mm),得用mm单位。CSS的mm在打印时是物理毫米,屏幕上是按96DPI换算的近似值,所以预览和打印会有细微差异,但打印结果准确。

.paper { --cell: 15mm; --weight: 0.2mm; }

线宽用0.2mm左右比较合适,太细打印机会丢线,太粗显得笨重。实测0.15mm到0.25mm是安全区间,具体看打印机精度。颜色方面,红色格子黑白打印会变成灰色,如果用户可能要黑白打印,建议提供一个“灰度模式”,把--line换成#888之类的深灰,对比度够又不刺眼。

第三个坑是分页。一页A4纸铺格子,如果格子尺寸没算好,最后一行可能被切到下一页,或者页面底部留一大块空白。解决办法是用@page控制页边距,并让格子容器的高度是格子单元高度的整数倍。

@page { size: A4; margin: 10mm; } .paper { height: calc(var(--unit) * 16); /* 16行,正好铺满 */ }

A4纸去掉10mm边距后,可用高度约277mm,15mm的格子加2mm间距是17mm,277/17≈16.3,所以铺16行比较稳。这个数得根据实际格子尺寸算,不能拍脑袋。我在项目里写了个小函数,输入纸张尺寸和格子尺寸,自动算出行列数,避免手动试错。

8. 参数化封装:一套CSS变量管住四种格子

四种格子画法不同,但对外接口应该统一。项目里我把它们封装成一套CSS变量,用类名切换格子类型,变量控制尺寸、线宽、颜色、间距。这样前端只需要改类名和变量,不用关心内部渐变怎么写。

变量名作用田字格米字格回字格九宫格
--cell格子边长必用必用必用必用
--line线条颜色必用必用必用必用
--weight线宽必用必用必用必用
--gap内框间距不用不用必用不用
--n宫格数不用不用不用可选

封装时有个经验:把公共部分(外框、尺寸、颜色)抽到一个基类,各格子类型只覆盖background-image和background-size。这样改公共参数时不用四处改,维护成本低。

.grid-base { width: var(--cell); height: var(--cell); border: var(--weight) solid var(--line); box-sizing: border-box; background-repeat: no-repeat; background-position: center; } .grid-tian { background-image: /* 田字格渐变 */; background-size: /* ... */; } .grid-mi { background-image: /* 米字格渐变 */; background-size: /* ... */; }

另一个容易忽略的点是响应式。格子纸在手机上预览时,固定px尺寸会溢出屏幕。解决办法是用min()把格子尺寸和视口宽度挂钩:

--cell: min(120px, 22vw);

这样大屏上格子是120px,小屏上自动缩到视口宽度的22%,一行铺4个左右,不会横向滚动。但要注意,打印时vw没有意义,得在@media print里覆盖回mm单位。这个切换我在项目里用媒体查询处理,实测下来预览和打印都正常。

9. 几个实测踩过的坑和对应解法

讲几个真金白银踩出来的问题,都是文档里不会写的。

坑一:渐变叠加顺序导致线被盖住。background-image里多个渐变是先写的在上层,后写的在下层。画米字格时,如果斜线写在横竖中线前面,斜线会盖住中线交叉点,看起来中线断了一截。正确顺序是把中线写在前面(上层),斜线写在后面(下层),这样交叉点显示的是中线。这个顺序问题在四种格里都存在,统一原则是“重要的线写前面”。

坑二:background-size用百分比时相对的是平铺单元而非元素。铺一页纸时,如果background-size里混用了100%和固定px,百分比相对的是background-size定义的那个单元尺寸,不是整个元素。这个容易搞混,导致线位置错乱。我的做法是铺排时全部用固定尺寸或calc,不用百分比,避免歧义。

坑三:高DPI屏幕上的1px线发虚。在2倍屏上,1px的CSS线实际渲染成2个物理像素,如果位置落在半像素上就会糊。解决办法是线宽用偶数px,或者用0.5px配合transform: scale。实测最稳的是线宽至少1px且格子尺寸为偶数,这样线永远落在整数像素上。

坑四:打印时边框和背景渐变对不齐。border画的外框和背景渐变画的中线,在打印时可能因为舍入差半个像素,看起来外框比中线粗一点。解决办法是外框也用背景渐变画,和线用同一套渲染路径,对齐问题就消失了。代价是代码变长,但打印质量提升明显。

坑五:CSS变量在旧版浏览器上的兼容。var()在主流浏览器上都支持,但如果项目要兼容很老的版本,得提供fallback值。我的做法是变量都带默认值:var(--line, #d94f4f),这样即使变量没定义也不会崩。

10. 还能往哪扩展:从四种格到一套练字工具

这套CSS格子画法跑通之后,扩展空间比想象中大。最直接的是加更多格子类型:比如“米字回宫格”(回字格加米字线)、“田字回宫格”,本质是渐变组的加法,把对应渐变叠进去就行。再比如“横线格”“竖线格”,用repeating-linear-gradient一行搞定,适合练行书。

往工具方向走,可以加描红功能:格子里叠一层半透明的字帖文字,用background-image叠文字图或者用::before放文字,调透明度。这个和格子渐变叠加不冲突,因为文字层可以用伪元素单独控制。再进一步是导出PDF,用浏览器的打印功能配合@page就能导出,不需要额外库。

还有一个实用扩展是格子尺寸预设。书法练习有标准尺寸,比如小学生田字格常见15mm、18mm,成人小楷可能10mm。把这些预设做成CSS类或者data属性,用户一键切换,比手动调变量友好。我在项目里用>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询