В этом мини-проекте соберём страницу-портфолио на HTML и CSS — от шапки с фотографией до блока с проектами и контактов. Готовый результат можно посмотреть прямо здесь, а ниже разобраны основные приёмы вёрстки: сетки, карточки, анимации и оформление отдельных блоков.
В этом разборе
Показать полный код страницы (можно скопировать целиком)
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Николай Дубук - портфолио</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;400;600;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--paper: #faf6f0;
--ink: #1c1a17;
--dim: #7a7468;
--accent: #e8590c;
--line: #e4ddd0;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: 'Manrope', 'Segoe UI', Arial, sans-serif;
}
a { text-decoration: none; color: inherit; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 0 28px; }
/* reveal-on-scroll */
.reveal { opacity: 0; transform: translateY(26px); transition: opacity .7s ease, transform .7s ease; }
.reveal.in-view { opacity: 1; transform: translateY(0); }
/* HERO */
.hero { padding: 70px 28px 50px; text-align: center; }
.avatar {
width: 130px; height: 130px;
border-radius: 50%;
object-fit: cover;
margin: 0 auto 30px;
display: block;
border: 1px solid var(--line);
}
.hero h1 {
margin: 0;
line-height: 1.05;
letter-spacing: -.01em;
}
.hero h1 .thin {
display: block;
font-weight: 200;
font-size: clamp(22px, 4vw, 34px);
text-transform: uppercase;
letter-spacing: .08em;
color: var(--dim);
}
.hero h1 .bold {
display: block;
font-weight: 800;
font-size: clamp(40px, 8vw, 76px);
text-transform: uppercase;
}
.hero p {
max-width: 480px;
margin: 22px auto 0;
color: var(--dim);
font-size: 17px;
line-height: 1.6;
}
.btn {
display: inline-block;
margin-top: 26px;
padding: 13px 28px;
border: 1px solid var(--ink);
border-radius: 999px;
font-size: 13px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
transition: background .25s ease, color .25s ease;
}
.btn:hover { background: var(--ink); color: var(--paper); }
.btn-accent { border-color: var(--accent); background: var(--accent); color: #fff; }
.btn-accent:hover { background: #c94a09; color: #fff; }
/* TICKER */
.ticker {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 14px 0;
overflow: hidden;
white-space: nowrap;
}
.ticker-track {
display: inline-block;
padding-left: 100%;
animation: scrollx 24s linear infinite;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
letter-spacing: .05em;
color: var(--dim);
text-transform: uppercase;
}
.ticker-track span { margin: 0 20px; }
.ticker-track span.accent { color: var(--accent); }
@keyframes scrollx { from { transform: translateX(0); } to { transform: translateX(-100%); } }
/* "Я верстальщик" split section */
.split {
display: grid;
grid-template-columns: 0.9fr 1.3fr;
gap: 26px;
align-items: center;
padding: 70px 28px;
}
.split-label {
font-size: clamp(28px, 5vw, 46px);
font-weight: 800;
text-transform: uppercase;
line-height: 1.1;
}
.split-label span { color: var(--accent); }
.panels { display: grid; grid-template-columns: 1.2fr 1fr; gap: 18px; }
.code-panel {
background: var(--ink);
color: #f2ede4;
border-radius: 16px;
padding: 22px 24px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.7;
overflow: hidden;
}
.code-panel .tag { color: #ff9d5c; }
.code-panel .attr { color: #7ee787; }
.code-panel .str { color: #9ecbff; }
.code-panel .cm { color: #8b8478; }
.stat-panel {
background: var(--accent);
color: #fff;
border-radius: 16px;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-panel .num { font-size: 46px; font-weight: 800; line-height: 1; margin-bottom: 6px; }
.stat-panel .lbl { font-size: 13px; text-transform: uppercase; letter-spacing: .05em; opacity: .9; }
/* BIG BROKEN HEADING */
.statement { padding: 70px 28px; text-align: center; }
.statement h2 {
font-size: clamp(38px, 9vw, 92px);
font-weight: 800;
text-transform: uppercase;
line-height: .98;
margin: 0 0 30px;
letter-spacing: -.01em;
}
.statement h2 .light { font-weight: 200; }
.statement h2 .accent { color: var(--accent); }
.rule { width: 60px; height: 2px; background: var(--ink); margin: 0 auto 30px; }
.statement p.quote {
max-width: 560px;
margin: 0 auto 34px;
font-style: italic;
font-size: 19px;
color: var(--dim);
line-height: 1.6;
}
.down-link { display: inline-flex; flex-direction: column; align-items: center; gap: 10px; font-size: 12px; text-transform: uppercase; letter-spacing: .1em; font-weight: 600; }
.down-arrow { animation: bob 1.8s ease-in-out infinite; font-size: 20px; }
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(6px); } }
/* PROJECT GRID */
.projects { padding: 30px 28px 90px; }
.grid5 {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 220px;
gap: 16px;
}
.pcard {
position: relative;
border-radius: 16px;
padding: 24px;
color: #fff;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
transition: transform .35s ease;
}
.pcard:hover { transform: translateY(-6px); }
.pcard.big { grid-column: span 2; }
.pcard h3 { margin: 0 0 6px; font-size: 20px; }
.pcard p { margin: 0 0 14px; font-size: 13px; opacity: .88; line-height: 1.5; max-width: 320px; }
.pcard .go { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; display: inline-flex; align-items: center; gap: 6px; }
.pcard .go .arrow { transition: transform .3s ease; }
.pcard:hover .go .arrow { transform: translateX(5px); }
.p1 { background: linear-gradient(135deg,#1c1a17,#3a352c); }
.p2 { background: linear-gradient(135deg,#e8590c,#ff8a3d); }
.p3 { background: linear-gradient(135deg,#2d5c4a,#4c9376); }
.p4 { background: linear-gradient(135deg,#5b4a8a,#8a6fd6); }
.p5 { background: linear-gradient(135deg,#8a4a3a,#c97a55); }
/* ABOUT TWO COL */
.about { padding: 70px 28px; border-top: 1px solid var(--line); }
.about-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 50px; }
.about h3 { font-size: 26px; margin: 0 0 16px; font-weight: 800; }
.about p { color: var(--dim); line-height: 1.65; font-size: 15px; margin: 0 0 18px; }
.values { display: flex; gap: 22px; margin-top: 16px; flex-wrap: wrap; }
.values span { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; padding: 8px 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--ink); }
/* FOOTER */
footer { background: var(--ink); color: #f2ede4; padding: 60px 28px; text-align: center; }
footer p { color: #b5ab99; margin: 0 0 26px; }
.social { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
.social a {
padding: 11px 26px;
border: 1px solid #4a453d;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
transition: background .25s ease, border-color .25s ease;
}
.social a:hover { background: var(--accent); border-color: var(--accent); }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
@media (max-width: 760px) {
.split { grid-template-columns: 1fr; text-align: center; }
.panels { grid-template-columns: 1fr; }
.grid5 { grid-template-columns: 1fr; grid-auto-rows: 200px; }
.pcard.big { grid-column: span 1; }
.about-grid { grid-template-columns: 1fr; gap: 30px; }
}
</style>
</head>
<body>
<header class="hero">
<img class="avatar" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj2ygCw8MfZ-AsLyuyAtW9hBXID12ad1pYFPt1h_MnXM-oa_1MsIdKrPUOFOm4uRUKuNFq219L-YZboPcr8jGkxqO2H2eZJNb8m3fc2e-o5-9ETqfrSsV2SSy8JXMkuLHKd_YqIVcqEdpjiV55i2NwXtDFAd7WKVSBzgY1duHPMtwDHB80q-AQ4Zqrhygg/s1600-rw/Nikolai_Dubuk_2.jpg" alt="Николай Дубук">
<h1><span class="thin">Автор курса</span><span class="bold">HTML & CSS</span></h1>
<p>Веду блоги на Blogger уже 11 лет. Учу вёрстке бесплатно - потому что бесплатных курсов по HTML и CSS на русском на удивление мало.</p>
<a class="btn" href="https://www.htmlicss.com/p/obo-mne.html" target="_blank" rel="noopener">Обо мне</a>
</header>
<div class="ticker">
<div class="ticker-track">
<span class="accent">HTML</span><span>·</span><span class="accent">CSS</span><span>·</span><span>GRID</span><span>·</span><span>FLEXBOX</span><span>·</span><span class="accent">BLOGGER</span><span>·</span><span>11 ЛЕТ В ДЕЛЕ</span><span>·</span>
<span class="accent">HTML</span><span>·</span><span class="accent">CSS</span><span>·</span><span>GRID</span><span>·</span><span>FLEXBOX</span><span>·</span><span class="accent">BLOGGER</span><span>·</span><span>11 ЛЕТ В ДЕЛЕ</span><span>·</span>
</div>
</div>
<section class="split reveal">
<div class="split-label">Я <span>верстальщик</span></div>
<div class="panels">
<div class="code-panel">
<span class="cm">/* портфолио на HTML и CSS */</span><br>
<span class="tag"><section</span> <span class="attr">class</span>=<span class="str">"projects"</span><span class="tag">></span><br>
<span class="tag"><article></span>...<span class="tag"></article></span><br>
<span class="tag"></section></span><br><br>
<span class="cm">.projects {</span><br>
display: grid;<br>
gap: 16px;<br>
<span class="cm">}</span>
</div>
<div class="stat-panel">
<div class="num">5</div>
<div class="lbl">своих проектов на Blogger</div>
</div>
</div>
</section>
<section class="statement reveal">
<h2><span class="light">Пишу</span> код<br><span class="accent">без команды</span><br><span class="light">и без</span> агентства</h2>
<div class="rule"></div>
<p class="quote">Я не делаю сайты ради денег - довожу проект до конца, чтобы доказать себе, что могу.</p>
<a class="down-link" href="#proekty">
<span class="down-arrow">↓</span>
Смотреть проекты
</a>
</section>
<section class="projects" id="proekty">
<div class="grid5 reveal">
<article class="pcard p1 big">
<h3>HTML&CSS - учимся вёрстке</h3>
<p>Бесплатный курс HTML и CSS с интерактивными примерами прямо в уроке.</p>
<a class="go" href="https://www.htmlicss.com/" target="_blank" rel="noopener">Открыть <span class="arrow">→</span></a>
</article>
<article class="pcard p2">
<h3>LeV Template</h3>
<p>Каталог шаблонов Blogger.</p>
<a class="go" href="https://lev-template.com/" target="_blank" rel="noopener">Открыть <span class="arrow">→</span></a>
</article>
<article class="pcard p3">
<h3>Путь блогера</h3>
<p>Гид по Blogger для новичков.</p>
<a class="go" href="https://bloggers-put.com/" target="_blank" rel="noopener">Открыть <span class="arrow">→</span></a>
</article>
<article class="pcard p4">
<h3>ВАЗ-2101 - Электрооборудование</h3>
<p>Блог об электрооборудовании ВАЗ-2101.</p>
<a class="go" href="https://vaz01.blogspot.com/" target="_blank" rel="noopener">Открыть <span class="arrow">→</span></a>
</article>
<article class="pcard p5 big">
<h3>Азбука пчеловода</h3>
<p>Сайт о пчеловодстве: биографические статьи и обзоры книг.</p>
<a class="go" href="https://www.azbuka-pchelovoda.com/" target="_blank" rel="noopener">Открыть <span class="arrow">→</span></a>
</article>
</div>
</section>
<section class="about reveal">
<div class="wrap about-grid">
<div>
<h3>Качество, честность, помощь</h3>
<p>Три принципа, на которых держатся все проекты выше - без команды, без агентства, без спонсоров.</p>
<div class="values">
<span>Качество</span>
<span>Честность</span>
<span>Помощь</span>
</div>
</div>
<div>
<h3>Как со мной связаться</h3>
<p>Быстрее всего отвечаю в Telegram. Есть вопрос по уроку или сайту - пишите, разберём.</p>
<a class="btn btn-accent" href="https://t.me/nikolaidubuk" target="_blank" rel="noopener">Написать в Telegram</a>
</div>
</div>
</section>
<footer>
<p>Николай Дубук - вёрстка, блоги, Blogger</p>
<div class="social">
<a href="https://t.me/nikolaidubuk" target="_blank" rel="noopener">Telegram</a>
<a href="mailto:bloggers.put@gmail.com">Email</a>
<a href="https://www.htmlicss.com/p/kontakti.html" target="_blank" rel="noopener">Все контакты</a>
</div>
</footer>
<script>
(function () {
var els = document.querySelectorAll('.reveal');
if (!('IntersectionObserver' in window)) {
els.forEach(function (el) { el.classList.add('in-view'); });
return;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
io.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
els.forEach(function (el) { io.observe(el); });
})();
// manual scroll for in-page anchors - needed when this page runs inside
// a srcdoc iframe, where the inherited parent base URL can stop the
// default anchor-jump behaviour from working
(function () {
document.querySelectorAll('a[href^="#"]').forEach(function (a) {
a.addEventListener('click', function (e) {
var id = a.getAttribute('href').slice(1);
var target = document.getElementById(id);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
})();
</script>
</body>
</html>
Шапка: фото и контраст шрифтов
Фото превращено в круг одним свойством border-radius: 50%. Под ним - имя в два уровня: тонкая надпись "Автор курса" (font-weight: 200) и жирный заголовок "HTML & CSS" (font-weight: 800) одним и тем же шрифтом - контраст толщины вместо контраста разных шрифтов.
HTML: что выводит эта разметка
Внутри <header> - фото, заголовок из двух вложенных <span> (один со стилем "тонко", другой "жирно"), абзац с текстом и ссылка-кнопка. Никакой магии в разметке нет - весь эффект дальше делает CSS.
<header class="hero">
<img class="avatar" src="..." alt="Николай Дубук">
<h1>
<span class="thin">Автор курса</span>
<span class="bold">HTML & CSS</span>
</h1>
<p>Веду блоги на Blogger уже 11 лет...</p>
<a class="btn" href="...">Обо мне</a>
</header>
Подробнее: Семантическая вёрстка: header, main, footer, Изображения: тег img и атрибут alt и Ссылки: тег a и его атрибуты.
CSS: как это оформлено
.avatar {
width: 130px;
height: 130px;
border-radius: 50%;
object-fit: cover;
}
.hero h1 .thin { font-weight: 200; }
.hero h1 .bold { font-weight: 800; }
Подробнее: Скругление углов: border-radius и Насыщенность шрифта: font-weight.
Бегущая строка снизу от шапки
Строка со словами HTML / CSS / GRID и так далее едет справа налево бесконечно. Приём простой: весь текст лежит в одном блоке с display: inline-block, а анимация через @keyframes сдвигает его свойством transform: translateX() от 0 до -100% и повторяется по кругу (infinite).
HTML: что выводит эта разметка
Внутри .ticker-track список слов записан не один раз, а два одинаковых блока подряд. Это нужно для бесшовной петли: пока первый набор слов уезжает влево, на его месте уже показывается второй - глаз не замечает момента, когда анимация начинается заново.
<div class="ticker">
<div class="ticker-track">
<span class="accent">HTML</span>
<span>·</span>
<span class="accent">CSS</span>
...
<!-- весь набор слов повторён ДВАЖДЫ подряд -->
</div>
</div>
Подробнее: Теги, атрибуты и вложенность.
CSS: как это оформлено
.ticker-track {
display: inline-block;
padding-left: 100%;
animation: scrollx 24s linear infinite;
}
@keyframes scrollx {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
Подробнее: Перемещение элементов: translate, translateX, translateY и Правило @keyframes: создаём анимацию с нуля.
Сетки страницы и крупные плашки проектов
Блок "Я верстальщик" и блок "Как со мной связаться" построены на display: grid с двумя колонками. В сетке проектов у части карточек указано grid-column: span 2 - такая карточка занимает сразу две колонки вместо одной, отсюда и разные по размеру плашки вместо ровной таблицы одинаковых квадратов.
HTML: что выводит эта разметка
Это обычные <section> и внутри них ровный список одинаковых <article class="pcard"> - никакой разницы в разметке между "большой" карточкой и обычной нет, они выглядят по-разному только благодаря CSS-классам p1-p5 и big.
<section class="split">
<div class="split-label">Я верстальщик</div>
<div class="panels">...</div>
</section>
<section class="projects">
<div class="grid5">
<article class="pcard p1 big">...</article>
<article class="pcard p2">...</article>
<article class="pcard p3">...</article>
<article class="pcard p4">...</article>
<article class="pcard p5 big">...</article>
</div>
</section>
Подробнее: nav, article, section, aside.
CSS: как это оформлено
.split {
display: grid;
grid-template-columns: 0.9fr 1.3fr;
gap: 26px;
}
.grid5 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.pcard.big { grid-column: span 2; }
Подробнее: Что такое CSS Grid и зачем он нужен, Создание сетки: grid-template-columns и grid-template-rows и Позиционирование в сетке: grid-column, grid-row и grid-area.
Подъём карточек при наведении
При наведении курсора карточка проекта приподнимается - это обычный transition на свойстве transform, который срабатывает при переходе в состояние :hover. Ничего не пересчитывается заново - браузер просто плавно анимирует переход между "было" и "стало".
HTML: что выводит эта разметка
Карточка - это <article class="pcard"> с заголовком, описанием и ссылкой-стрелкой внутри. В самой разметке нет ни слова про наведение - состояние :hover ниже добавляет CSS, а не HTML.
<article class="pcard p1 big">
<h3>HTML&CSS - учимся вёрстке</h3>
<p>Бесплатный курс HTML и CSS...</p>
<a class="go" href="...">Открыть <span class="arrow">→</span></a>
</article>
Подробнее: nav, article, section, aside - тот же тег article, что и в сетке проектов выше.
CSS: как это оформлено
.pcard {
transition: transform .35s ease;
}
.pcard:hover {
transform: translateY(-6px);
}
Подробнее: Анимация при наведении: практика с transition и :hover и Анимированные кнопки и ссылки: практика.
Появление блоков при прокрутке
Блоки страницы изначально прозрачные и сдвинутые вниз (opacity: 0), а когда блок появляется в зоне видимости при скролле, ему через JavaScript (IntersectionObserver) добавляется класс, который CSS-переходом плавно возвращает его на место. У страницы также есть правило @media (prefers-reduced-motion: reduce) - оно отключает все анимации для тех, кто в настройках операционной системы попросил браузер их не показывать.
HTML: что выводит эта разметка
К нужным секциям просто добавлен ещё один класс - reveal - рядом с их обычным классом. Сам по себе он ничего не делает: это просто "флажок", за который цепляется и CSS (прячет блок), и JavaScript (следит за прокруткой и снимает флажок в нужный момент).
<section class="split reveal">...</section>
<section class="statement reveal">...</section>
<div class="grid5 reveal">...</div>
<section class="about reveal">...</section>
Подробнее: Теги, атрибуты и вложенность - class здесь обычный атрибут, просто использованный как переключатель для CSS и JS.
CSS + JS: как это оформлено
.reveal {
opacity: 0;
transform: translateY(26px);
transition: opacity .7s ease, transform .7s ease;
}
.reveal.in-view {
opacity: 1;
transform: translateY(0);
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
io.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; }
}
Подробнее: Анимация появления элементов при загрузке страницы и prefers-reduced-motion: уважение к выбору пользователя.