<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Web Development - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Web Development - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 27 Aug 2026 01:02:57 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/web-development/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build More Accessible Websites with WCAG 2.2 ]]>
                </title>
                <description>
                    <![CDATA[ A website can look polished, work perfectly with a mouse, and still be difficult for some people to use. A form might use colour as the only indication that something went wrong. A sticky header might ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-more-accessible-websites-with-wcag-2-2/</link>
                <guid isPermaLink="false">6a84895dd197512208831afc</guid>
                
                    <category>
                        <![CDATA[ Accessibility ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #WCAG ]]>
                    </category>
                
                    <category>
                        <![CDATA[ wcag compliance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Aiyedogbon Abraham ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 16:33:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5cb22632-e793-40cc-9ded-4b813430e708.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A website can look polished, work perfectly with a mouse, and still be difficult for some people to use.</p>
<p>A form might use colour as the only indication that something went wrong. A sticky header might completely cover the element that currently has keyboard focus. A login form might prevent users from pasting a password from their password manager. Or a custom button might work when clicked with a mouse but do nothing when someone uses a keyboard.</p>
<p>These are development decisions, not problems that only appear during an accessibility audit.</p>
<p>The Web Content Accessibility Guidelines (WCAG) provide a common standard for identifying and reducing many of these barriers. WCAG 2.2 is the latest WCAG 2 Recommendation, and the World Wide Web Consortium (W3C) advises developers and organisations to use WCAG 2.2 whenever possible.</p>
<p>This article focuses on the WCAG 2.2 Level A and AA requirements that frequently affect frontend development. The aim is to show how accessibility requirements connect to frontend development decisions.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-wcag-22">What Is WCAG 2.2?</a></p>
</li>
<li><p><a href="#heading-how-wcag-conformance-works">How WCAG Conformance Works</a></p>
</li>
<li><p><a href="#heading-how-the-four-wcag-principles-work">How the Four WCAG Principles Work</a></p>
</li>
<li><p><a href="#heading-how-to-start-with-semantic-html">How to Start with Semantic HTML</a></p>
</li>
<li><p><a href="#heading-how-to-write-useful-text-alternatives-for-images">How to Write Useful Text Alternatives for Images</a></p>
</li>
<li><p><a href="#heading-how-to-handle-colour-contrast-text-resizing-and-reflow">How to Handle Colour, Contrast, Text Resizing, and Reflow</a></p>
</li>
<li><p><a href="#heading-how-to-make-an-interface-work-with-a-keyboard">How to Make an Interface Work with a Keyboard</a></p>
</li>
<li><p><a href="#heading-how-to-keep-keyboard-focus-visible">How to Keep Keyboard Focus Visible</a></p>
</li>
<li><p><a href="#heading-how-to-design-pointer-targets-and-dragging-interactions">How to Design Pointer Targets and Dragging Interactions</a></p>
</li>
<li><p><a href="#heading-how-to-build-more-accessible-forms">How to Build More Accessible Forms</a></p>
</li>
<li><p><a href="#heading-how-to-avoid-redundant-entry">How to Avoid Redundant Entry</a></p>
</li>
<li><p><a href="#heading-how-to-keep-help-consistent">How to Keep Help Consistent</a></p>
</li>
<li><p><a href="#heading-how-wcag-22-affects-authentication">How WCAG 2.2 Affects Authentication</a></p>
</li>
<li><p><a href="#heading-how-to-use-aria-without-replacing-html">How to Use ARIA Without Replacing HTML</a></p>
</li>
<li><p><a href="#heading-how-to-make-dynamic-status-messages-accessible">How to Make Dynamic Status Messages Accessible</a></p>
</li>
<li><p><a href="#heading-how-to-test-your-website-for-accessibility">How to Test Your Website for Accessibility</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-wcag-22">What Is WCAG 2.2?</h2>
<p>WCAG stands for <strong>Web Content Accessibility Guidelines</strong>. W3C develops the standard to describe how web content can be made more accessible to people with disabilities.</p>
<p>WCAG 2.2 organises its requirements into principles, guidelines, and testable success criteria. The success criteria are technology-independent, which is important because WCAG doesn't exist specifically for HTML, React, WordPress, or any other implementation technology.</p>
<p>Consider this hierarchy:</p>
<pre><code class="language-text">Principle: Operable

    Guideline 2.1: Keyboard Accessible

        Success Criterion 2.1.1: Keyboard
</code></pre>
<p>The principle gives you the broad accessibility objective. The guideline narrows that objective, while the success criterion provides the testable requirement.</p>
<p>W3C also publishes resources such as <a href="https://www.w3.org/WAI/WCAG22/Understanding/">Understanding WCAG 2.2</a>, <a href="https://www.w3.org/WAI/WCAG22/quickref/">How to Meet WCAG 2.2</a>, and <a href="https://www.w3.org/WAI/WCAG22/Techniques/">Techniques for WCAG 2.2</a>. These resources explain the success criteria and provide implementation approaches, examples, and known failures. They're informative rather than part of the normative WCAG requirements.</p>
<p>A W3C technique can show one recognised way to satisfy a criterion, but WCAG generally doesn't require you to use that exact technique. Another implementation can also be valid if it meets the actual success criterion.</p>
<h2 id="heading-how-wcag-conformance-works">How WCAG Conformance Works</h2>
<p>WCAG defines three conformance levels: <strong>A, AA,</strong> and <strong>AAA</strong>.</p>
<p>The levels build on one another. A page can't claim Level AA conformance by satisfying only the criteria labelled AA. It must satisfy all applicable Level A and Level AA success criteria. Level AAA similarly includes A, AA, and AAA requirements.</p>
<p>This distinction is important because accessibility discussions sometimes reduce WCAG to individual checks.</p>
<p>You might fix the keyboard interaction on a menu, add alternatives to your images, and correct several contrast problems. Those are useful accessibility improvements, but they don't automatically make the entire website "WCAG AA compliant".</p>
<p>WCAG conformance applies to complete web pages. When a process requires several pages to complete, such as a checkout process, all pages in that process must conform at the claimed level.</p>
<p>The examples in this article therefore demonstrate ways to address particular accessibility requirements. They don't constitute a conformance claim for an entire application.</p>
<h2 id="heading-how-the-four-wcag-principles-work">How the Four WCAG Principles Work</h2>
<p>WCAG groups its guidelines under four principles commonly remembered with the acronym <strong>POUR</strong>: Perceivable, Operable, Understandable, and Robust.</p>
<p><strong>Perceivable</strong> means users need to be able to perceive the information you provide. Text alternatives, captions, contrast, and adaptable layouts fall under this principle.</p>
<p><strong>Operable</strong> concerns how people interact with the interface. Keyboard operation, focus behaviour, navigation, pointer interactions, and timing are examples.</p>
<p><strong>Understandable</strong> deals with whether users can understand the information and the way the interface behaves. Form instructions, useful error messages, predictable interfaces, and accessible authentication are relevant here.</p>
<p><strong>Robust</strong> concerns whether browsers and assistive technologies can correctly interpret the content. Semantic HTML, accessible names, roles, values, and states are central to this principle.</p>
<p>These categories are useful, but accessibility problems rarely respect the boundary between HTML, CSS, and JavaScript.</p>
<p>A custom dropdown, for example, might need semantic information in the markup, visible focus styling in CSS, and correct keyboard behaviour in JavaScript.</p>
<p>Accessibility therefore works best when it forms part of the implementation itself rather than becoming a separate task at the end of development.</p>
<h2 id="heading-how-to-start-with-semantic-html">How to Start with Semantic HTML</h2>
<p>One of the most useful accessibility decisions happens before you write any ARIA: choosing the correct HTML element.</p>
<p>Consider this:</p>
<pre><code class="language-html">&lt;div onclick="submitForm()"&gt;Submit&lt;/div&gt;
</code></pre>
<p>A mouse user may be able to click the element, but a <code>div</code> doesn't automatically behave like a button.</p>
<p>Compare it with this:</p>
<pre><code class="language-html">&lt;button type="submit"&gt;Submit&lt;/button&gt;
</code></pre>
<p>The native <code>button</code> already communicates its role to the browser and provides the expected keyboard behaviour.</p>
<p>This relates to <a href="https://www.w3.org/TR/WCAG22/#name-role-value">Success Criterion 4.1.2 Name, Role, Value</a>, which requires user interface components to expose information such as their name and role programmatically. W3C notes that standard controls already provide much of this information when developers use them according to their specification.</p>
<p>The practical implication is simple: don't recreate browser behaviour unless you need to.</p>
<h3 id="heading-how-semantic-html-communicates-page-structure">How Semantic HTML Communicates Page Structure</h3>
<p>Semantic HTML also helps expose relationships between parts of a page.</p>
<p>You could build a page like this:</p>
<pre><code class="language-html">&lt;div class="top"&gt;
  ...
&lt;/div&gt;

&lt;div class="navigation"&gt;
  ...
&lt;/div&gt;

&lt;div class="content"&gt;
  &lt;div class="title"&gt;Account Settings&lt;/div&gt;
  ...
&lt;/div&gt;
</code></pre>
<p>The classes may create the visual layout you want, but they don't necessarily communicate the same structure programmatically.</p>
<p>A more meaningful structure could be:</p>
<pre><code class="language-html">&lt;header&gt;
  ...
&lt;/header&gt;

&lt;nav aria-label="Primary"&gt;
  ...
&lt;/nav&gt;

&lt;main id="main-content"&gt;
  &lt;h1&gt;Account Settings&lt;/h1&gt;
  ...
&lt;/main&gt;
</code></pre>
<p><a href="https://www.w3.org/TR/WCAG22/#info-and-relationships">Success Criterion 1.3.1 Info and Relationships</a> requires structure and relationships communicated visually to also be programmatically determinable or available in text. Semantic markup can provide this information without requiring developers to recreate it with additional accessibility attributes.</p>
<p>This doesn't mean that using <code>&lt;main&gt;</code>, <code>&lt;nav&gt;</code>, and <code>&lt;h1&gt;</code> automatically makes a page accessible. It means you're giving the browser more accurate information about what the content represents.</p>
<h3 id="heading-how-to-add-a-skip-link">How to Add a Skip Link</h3>
<p>Repeated page navigation creates another issue.</p>
<p>If a page has a large navigation menu, a keyboard user may otherwise need to move through those links every time before reaching the main content.</p>
<p>A skip link provides another path:</p>
<pre><code class="language-html">&lt;a class="skip-link" href="#main-content"&gt;
  Skip to main content
&lt;/a&gt;

&lt;header&gt;
  ...
&lt;/header&gt;

&lt;nav aria-label="Primary"&gt;
  ...
&lt;/nav&gt;

&lt;main id="main-content"&gt;
  ...
&lt;/main&gt;
</code></pre>
<p>You can position the link outside the normal view until it receives keyboard focus:</p>
<pre><code class="language-css">.skip-link {
  position: absolute;
  top: -4rem;
  left: 1rem;
}

.skip-link:focus {
  top: 1rem;
}
</code></pre>
<p>This is one recognised way to support <a href="https://www.w3.org/TR/WCAG22/#bypass-blocks">Success Criterion 2.4.1 Bypass Blocks</a>, which requires a mechanism for bypassing blocks of repeated content. WCAG requires the outcome rather than this exact CSS implementation.</p>
<p>The broader principle is worth keeping: <strong>use HTML's existing semantics before adding custom semantics yourself</strong>.</p>
<h2 id="heading-how-to-write-useful-text-alternatives-for-images">How to Write Useful Text Alternatives for Images</h2>
<p>Adding <code>alt</code> text is one of the best-known accessibility practices, but the rule is often oversimplified.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#non-text-content">Success Criterion 1.1.1 Non-text Content</a> requires non-text content to have a text alternative that serves an equivalent purpose, subject to several exceptions. Decorative content, for example, should be implemented so assistive technologies can ignore it.</p>
<p>The important word is <strong>purpose</strong>.</p>
<p>Consider this image:</p>
<pre><code class="language-html">&lt;img src="revenue-chart.png" alt="Chart"&gt;
</code></pre>
<p>The alternative tells the user that the page contains a chart. It doesn't communicate anything the chart actually tells a sighted user.</p>
<p>If the main message is the change in revenue, an alternative could be:</p>
<pre><code class="language-html">&lt;img
  src="revenue-chart.png"
  alt="Revenue increased from £1.2 million in 2024 to
       £1.8 million in 2025."
&gt;
</code></pre>
<p>That doesn't mean every chart can be reduced to one sentence.</p>
<p>If the chart contains several data series or detailed values that readers need, you may also need a nearby explanation, accessible table, or another way of communicating the underlying information.</p>
<p>The alternative should reflect what the image contributes in its context.</p>
<h3 id="heading-how-to-handle-decorative-images">How to Handle Decorative Images</h3>
<p>A decorative image serves a different purpose.</p>
<p>Consider a visual divider:</p>
<pre><code class="language-html">&lt;img src="decorative-line.svg" alt=""&gt;
</code></pre>
<p>The empty <code>alt</code> value indicates that the image doesn't contribute information that needs to be announced.</p>
<p>A missing <code>alt</code> attribute and <code>alt=""</code> are therefore not interchangeable. The empty alternative is an intentional decision.</p>
<h3 id="heading-how-to-handle-icons-inside-controls">How to Handle Icons Inside Controls</h3>
<p>Now consider a search button containing a magnifying-glass SVG.</p>
<p>The relevant information isn't that the user is looking at a magnifying glass. The important information is that the control starts a search.</p>
<pre><code class="language-html">&lt;button type="submit" aria-label="Search"&gt;
  &lt;svg aria-hidden="true" viewBox="0 0 24 24"&gt;
    &lt;path d="M10 4a6 6 0 1 0 0 12a6 6 0 0 0 0-12Z"&gt;&lt;/path&gt;
    &lt;path d="m14.5 14.5 5 5"&gt;&lt;/path&gt;
  &lt;/svg&gt;
&lt;/button&gt;
</code></pre>
<p>The button receives the accessible name <code>Search</code>, while the SVG itself doesn't add duplicate information.</p>
<p>When deciding what alternative to provide, ask a more useful question than "What does this image look like?"</p>
<p>Ask: <strong>What information or function would the user lose if they couldn't perceive this image visually?</strong> That distinction matters when implementing accessibility.</p>
<h2 id="heading-how-to-handle-colour-contrast-text-resizing-and-reflow">How to Handle Colour, Contrast, Text Resizing, and Reflow</h2>
<p>Accessibility also affects ordinary CSS decisions.</p>
<p>A layout may look correct at your preferred viewport size and still become difficult to use when somebody changes the way content is displayed.</p>
<h3 id="heading-how-to-avoid-relying-only-on-colour">How to Avoid Relying Only on Colour</h3>
<p>Imagine a form that changes an input border from grey to red when validation fails:</p>
<pre><code class="language-css">.input {
  border: 1px solid #777;
}

.input.error {
  border-color: red;
}
</code></pre>
<p>The colour communicates that something changed, but a user needs to perceive that colour difference to understand the state.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#use-of-color">Success Criterion 1.4.1 Use of Color</a> requires colour not to be the only visual means used to convey information, indicate an action, prompt a response, or distinguish a visual element.</p>
<p>An improved implementation can combine styling with actual text:</p>
<pre><code class="language-html">&lt;label for="email"&gt;Email address&lt;/label&gt;

&lt;input
  id="email"
  name="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-error"
&gt;

&lt;p id="email-error"&gt;
  Enter an email address in the format name@example.com.
&lt;/p&gt;
</code></pre>
<p>You can still use a red border. It just shouldn't carry the message alone.</p>
<h3 id="heading-how-to-check-text-contrast">How to Check Text Contrast</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#contrast-minimum">Success Criterion 1.4.3 Contrast (Minimum)</a> requires regular text to have a contrast ratio of at least <strong>4.5:1</strong>. Qualifying large-scale text has a minimum ratio of <strong>3:1</strong>, subject to the criterion's exceptions.</p>
<p>Don't judge contrast only by looking at the colours. Two colours can look sufficiently different on your display while still falling below the required ratio. Use a contrast-testing tool as part of your design and development process.</p>
<h3 id="heading-how-non-text-contrast-differs-from-text-contrast">How Non-Text Contrast Differs from Text Contrast</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#non-text-contrast">Success Criterion 1.4.11 Non-text Contrast</a> deals with visual information needed to identify interface components, states, and meaningful graphical objects. The required ratio is generally <strong>3:1</strong> against adjacent colours, subject to the criterion's scope and exceptions.</p>
<p>This can affect things such as custom form controls, meaningful icons, component boundaries, selected states, and graphical information.</p>
<p>Passing the text contrast requirement therefore doesn't automatically mean the rest of the interface has sufficient contrast.</p>
<h3 id="heading-how-to-support-text-resizing">How to Support Text resizing</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#resize-text">Success Criterion 1.4.4 Resize Text</a> requires text, with specified exceptions, to be resizable up to 200% without loss of content or functionality.</p>
<p>Fixed dimensions often expose problems here.</p>
<p>Consider:</p>
<pre><code class="language-css">.card {
  height: 180px;
  overflow: hidden;
}
</code></pre>
<p>If text grows beyond the space the developer assumed it would need, some content can disappear.</p>
<p>Where the design doesn't genuinely require a fixed height, allowing the component to grow is safer:</p>
<pre><code class="language-css">.card {
  min-height: 180px;
}
</code></pre>
<p>This doesn't prove that the component passes the criterion. You still need to resize the text and inspect the result.</p>
<p>The CSS simply removes one common source of failure.</p>
<h3 id="heading-how-to-design-for-reflow">How to Design for Reflow</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#reflow">Success Criterion 1.4.10 Reflow</a> addresses the ability to use content at narrow equivalent dimensions without losing information or functionality or requiring prohibited two-dimensional scrolling.</p>
<p>For vertically scrolling content, the criterion uses a width equivalent to <strong>320 CSS pixels</strong>. Certain content, such as some maps and data tables, may genuinely require two-dimensional layout and falls under the criterion's exceptions.</p>
<p>A flexible layout can help ordinary content adapt:</p>
<pre><code class="language-css">.settings-grid {
  display: grid;
  grid-template-columns:
    repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: 1rem;
}
</code></pre>
<p>As space decreases, the cards move onto new rows rather than forcing the entire page to remain wide.</p>
<p>Responsive design helps here, but "responsive" and "accessible" aren't synonyms.</p>
<p>A responsive page can still hide controls, clip text, overlap content, or remove functionality at high zoom. Test the behaviour rather than assuming a media query solves the accessibility requirement.</p>
<h2 id="heading-how-to-make-an-interface-work-with-a-keyboard">How to Make an Interface Work with a Keyboard</h2>
<p>One of the simplest manual accessibility tests is to put the mouse aside and use the application with a keyboard.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#keyboard">Success Criterion 2.1.1 Keyboard</a> requires functionality to be operable through a keyboard interface except where the underlying function genuinely depends on the path of the user's movement. WCAG doesn't prevent the interface from also supporting mouse, touch, voice, or other forms of input.</p>
<p>Let's return to our custom control:</p>
<pre><code class="language-html">&lt;div onclick="saveSettings()"&gt;Save&lt;/div&gt;
</code></pre>
<p>Making the <code>div</code> look like a button doesn't give it button behaviour.</p>
<p>You could begin rebuilding that behaviour yourself:</p>
<pre><code class="language-html">&lt;div
  role="button"
  tabindex="0"
&gt;
  Save
&lt;/div&gt;
</code></pre>
<p>But now your JavaScript must also provide the appropriate keyboard interaction.</p>
<p>In most cases, this is unnecessary:</p>
<pre><code class="language-html">&lt;button type="button"&gt;
  Save
&lt;/button&gt;
</code></pre>
<p>Native controls reduce the amount of interaction behaviour you need to reproduce.</p>
<p>W3C's ARIA Authoring Practices Guide makes this distinction explicit: ARIA roles don't cause browsers to add the keyboard behaviour that comes with native HTML controls. If you create a custom ARIA widget, you're responsible for implementing those interactions.</p>
<h3 id="heading-how-to-check-for-keyboard-traps">How to Check for Keyboard Traps</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#no-keyboard-trap">Success Criterion 2.1.2 No Keyboard Trap</a> addresses situations where keyboard focus enters a component but can't leave through a keyboard interface.</p>
<p>This is particularly relevant to custom editors, dialogs, embedded widgets, and other complex controls.</p>
<p>Keyboard testing should go beyond asking whether you can press <code>Tab</code> until an element receives focus.</p>
<p>Try to complete the actual task. If you open a dialog, can you use its controls and close it? If you enter a custom widget, can you leave it? If a menu opens, can you operate it using its expected keyboard pattern?</p>
<p>Keyboard accessibility concerns the whole interaction, not simply whether an element appears in the tab order.</p>
<h2 id="heading-how-to-keep-keyboard-focus-visible">How to Keep Keyboard Focus Visible</h2>
<p>Keyboard navigation becomes difficult when users can't tell which element currently has focus.</p>
<p>This CSS is therefore risky:</p>
<pre><code class="language-css">*:focus {
  outline: none;
}
</code></pre>
<p>It removes the browser's default focus indication without providing an alternative.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#focus-order">Success Criterion 2.4.3 Focus Order</a> requires a keyboard-operable interface to provide a mode in which the keyboard focus indicator is visible.</p>
<p>If the default outline doesn't fit your design, replace it with another visible focus treatment rather than simply removing it:</p>
<pre><code class="language-css">button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 3px solid currentColor;
  outline-offset: 3px;
}
</code></pre>
<p>This is an example, not a guarantee of conformance. Your chosen indicator still needs to remain visible against the colours surrounding the component.</p>
<h3 id="heading-how-wcag-22-deals-with-obscured-focus">How WCAG 2.2 Deals with Obscured Focus</h3>
<p>WCAG 2.2 added <a href="https://www.w3.org/TR/WCAG22/#focus-not-obscured-minimum">Success Criterion 2.4.11 Focus Not Obscured (Minimum)</a> at Level AA.</p>
<p>When a user interface component receives keyboard focus, author-created content must not completely hide it. The Level AA criterion requires at least part of the focused component to remain visible.</p>
<p>A sticky header illustrates the problem:</p>
<pre><code class="language-css">.site-header {
  position: sticky;
  top: 0;
  height: 5rem;
}
</code></pre>
<p>There's nothing inherently inaccessible about a sticky header. The problem occurs if the page scrolls a focused link or control entirely behind that header.</p>
<p>CSS such as this can help when scroll positioning is involved:</p>
<pre><code class="language-css">html {
  scroll-padding-top: 6rem;
}
</code></pre>
<p>But don't treat it as a complete fix.</p>
<p>Test the real keyboard interaction because cookie notices, fixed bottom navigation, chat windows, sticky toolbars, and other overlays can create similar problems.</p>
<p>The important requirement is the outcome: when focus moves, the user should still be able to see the focused component.</p>
<h2 id="heading-how-to-design-pointer-targets-and-dragging-interactions">How to Design Pointer Targets and Dragging Interactions</h2>
<p>Keyboard support doesn't cover every interaction barrier.</p>
<p>WCAG 2.2 introduced additional requirements that are particularly relevant to touchscreens, drag-and-drop interfaces, and compact controls.</p>
<h3 id="heading-how-to-provide-an-alternative-to-dragging">How to Provide an Alternative to Dragging</h3>
<p>Imagine a task board where users reorder cards only by dragging them.</p>
<p>Dragging may work well for many users, but it depends on pressing a pointer, moving it while maintaining that interaction, and releasing it in the correct place.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#dragging-movements">Success Criterion 2.5.7 Dragging Movements</a> requires functionality that uses a dragging movement to also be achievable without dragging through a single-pointer operation, unless dragging is essential to the function.</p>
<p>You can keep drag-and-drop while providing another control:</p>
<pre><code class="language-html">&lt;article class="task"&gt;
  &lt;h3&gt;Prepare monthly report&lt;/h3&gt;

  &lt;button type="button"&gt;
    Move up
  &lt;/button&gt;

  &lt;button type="button"&gt;
    Move down
  &lt;/button&gt;
&lt;/article&gt;
</code></pre>
<p>The exact reorder logic depends on your application.</p>
<p>The important part is that the user has another pointer-based way to perform the same function without having to drag the card.</p>
<p>WCAG isn't saying "don't use drag-and-drop". It's saying that dragging shouldn't unnecessarily become the only route to the functionality.</p>
<h3 id="heading-how-to-think-about-target-size">How to Think About Target Size</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#target-size-minimum">Success Criterion 2.5.8 Target Size (Minimum)</a> is another WCAG 2.2 Level AA addition.</p>
<p>The criterion uses a minimum target size of <strong>24 by 24 CSS pixels</strong> or a defined spacing alternative and contains several exceptions. It therefore should not be simplified to "every clickable element must always be at least 24 pixels wide and high".</p>
<p>For an isolated icon button, you can choose to provide an even larger target:</p>
<pre><code class="language-css">.icon-button {
  min-width: 2.75rem;
  min-height: 2.75rem;

  display: inline-grid;
  place-items: center;
}
</code></pre>
<p>At a typical root font size, this deliberately creates a target larger than the WCAG minimum.</p>
<p>The visible icon can remain smaller:</p>
<pre><code class="language-html">&lt;button
  class="icon-button"
  type="button"
  aria-label="Delete invoice"
&gt;
  &lt;svg
    width="16"
    height="16"
    aria-hidden="true"
    viewBox="0 0 16 16"
  &gt;
    &lt;path d="M3 4h10M6 4V2h4v2M5 6v7M8 6v7M11 6v7"&gt;&lt;/path&gt;
  &lt;/svg&gt;
&lt;/button&gt;
</code></pre>
<p>The size of the icon and the size of the interactive target are not the same thing.</p>
<p>That distinction is useful when designing dense interfaces.</p>
<h3 id="heading-how-to-keep-the-accessible-name-aligned-with-the-visible-label">How to Keep the Accessible Name Aligned with the Visible Label</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#label-in-name">Success Criterion 2.5.3 Label in Name</a> concerns controls that have a visible text label.</p>
<p>The accessible name should contain the visible label text. This is particularly important for users who operate interfaces using speech and refer to controls by the words they can see.</p>
<p>Avoid this:</p>
<pre><code class="language-html">&lt;button aria-label="Find products"&gt;
  Search
&lt;/button&gt;
</code></pre>
<p>The visible label is <code>Search</code>, but the accessible name is <code>Find products</code>.</p>
<p>In this case, the simplest version is better:</p>
<pre><code class="language-html">&lt;button&gt;
  Search
&lt;/button&gt;
</code></pre>
<p>If additional accessible context is genuinely necessary, retain the visible wording:</p>
<pre><code class="language-html">&lt;button aria-label="Search products"&gt;
  Search
&lt;/button&gt;
</code></pre>
<p>Before adding an <code>aria-label</code>, check whether the visible text already gives the control an adequate accessible name.</p>
<h2 id="heading-how-to-build-more-accessible-forms">How to Build More Accessible Forms</h2>
<p>Forms combine several areas of accessibility: structure, instructions, errors, input purpose, and status changes.</p>
<p>Start with the field itself.</p>
<h3 id="heading-how-to-label-form-controls">How to Label Form Controls</h3>
<p>This pattern is common:</p>
<pre><code class="language-html">&lt;input
  type="email"
  name="email"
  placeholder="Email address"
&gt;
</code></pre>
<p>The placeholder provides a visual hint, but it's not a good replacement for a proper label.</p>
<p>Use:</p>
<pre><code class="language-html">&lt;label for="email"&gt;
  Email address
&lt;/label&gt;

&lt;input
  id="email"
  name="email"
  type="email"
&gt;
</code></pre>
<p><a href="https://www.w3.org/TR/WCAG22/#labels-or-instructions">Success Criterion 3.3.2 Labels or Instructions</a> requires labels or instructions when content requires user input.</p>
<p>The <code>for</code> and <code>id</code> values also create a programmatic relationship between the label and the field.</p>
<h3 id="heading-how-to-identify-common-input-purposes">How to Identify Common Input Purposes</h3>
<p><a href="https://www.w3.org/TR/WCAG22/#identify-input-purpose">Success Criterion 1.3.5 Identify Input Purpose</a> applies to fields collecting certain types of information about the user. Their purpose needs to be programmatically determinable when the technology supports it.</p>
<p>HTML's <code>autocomplete</code> tokens help communicate common purposes:</p>
<pre><code class="language-html">&lt;label for="full-name"&gt;
  Full name
&lt;/label&gt;

&lt;input
  id="full-name"
  name="full-name"
  type="text"
  autocomplete="name"
&gt;

&lt;label for="email"&gt;
  Email address
&lt;/label&gt;

&lt;input
  id="email"
  name="email"
  type="email"
  autocomplete="email"
&gt;
</code></pre>
<p>This also allows browsers and other tools to provide useful input assistance.</p>
<h3 id="heading-how-to-write-useful-validation-errors">How to Write Useful Validation Errors</h3>
<p>Now consider an error message:</p>
<pre><code class="language-text">Invalid input.
</code></pre>
<p>The message tells the user almost nothing: Which input is invalid? What's wrong with it? What needs to change?</p>
<p><a href="https://www.w3.org/TR/WCAG22/#error-identification">Success Criterion <strong>3.3.1 Error Identification</strong></a> requires an automatically detected input error to identify the item in error and describe the error in text.</p>
<p>An implementation might look like this:</p>
<pre><code class="language-html">&lt;label for="email"&gt;
  Email address
&lt;/label&gt;

&lt;input
  id="email"
  name="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-error"
&gt;

&lt;p id="email-error"&gt;
  Enter an email address in the format name@example.com.
&lt;/p&gt;
</code></pre>
<p><code>aria-invalid="true"</code> exposes the invalid state. <code>aria-describedby</code> associates the explanation with the field.</p>
<p>More importantly, the message tells the user what needs correcting.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#error-suggestion">Success Criterion 3.3.3 Error Suggestion</a> goes further at Level AA. When the system detects an input error and knows how it can be corrected, it should provide an appropriate suggestion unless doing so would compromise the security or purpose of the content.</p>
<p>The aim isn't to make every error message long. The aim is to make it actionable.</p>
<h2 id="heading-how-to-avoid-redundant-entry">How to Avoid Redundant Entry</h2>
<p>Consider a checkout process.</p>
<p>The user enters a delivery address on one step. The next step asks them to type exactly the same address again for billing.</p>
<p>WCAG 2.2 introduced <a href="https://www.w3.org/TR/WCAG22/#redundant-entry">Success Criterion 3.3.7 Redundant Entry</a> at Level A.</p>
<p>When information previously entered by or provided to the user is required again during the same process, the information must generally be auto-populated or available for the user to select. The criterion includes exceptions where re-entry is essential, necessary for security, or where the previous information is no longer valid.</p>
<p>A checkout might offer:</p>
<pre><code class="language-html">&lt;label&gt;
  &lt;input
    type="checkbox"
    name="billing-same-as-delivery"
  &gt;
  Use my delivery address as my billing address
&lt;/label&gt;
</code></pre>
<p>Notice that the requirement concerns information within the same process. It doesn't mean every website has to remember every value a user entered during earlier visits.</p>
<p>This criterion also shows why accessibility extends beyond screen-reader support.</p>
<p>Reducing unnecessary repetition can lower the cognitive and interaction effort required to complete a task.</p>
<h2 id="heading-how-to-keep-help-consistent">How to Keep Help Consistent</h2>
<p>WCAG 2.2 also added <a href="https://www.w3.org/TR/WCAG22/#consistent-help">Success Criterion 3.2.6 Consistent Help</a> at Level A.</p>
<p>If certain help mechanisms appear repeatedly across a set of pages, they need to appear in the same relative order unless the user initiates a change. These mechanisms can include human contact details, contact mechanisms, self-help options, and automated contact mechanisms.</p>
<p>Suppose your account pages all provide a support link in the header:</p>
<pre><code class="language-html">&lt;header&gt;
  &lt;a href="/"&gt;Acme&lt;/a&gt;

  &lt;nav aria-label="Primary"&gt;
    &lt;!-- Navigation links --&gt;
  &lt;/nav&gt;

  &lt;a href="/support"&gt;Support&lt;/a&gt;
&lt;/header&gt;
</code></pre>
<p>Do not move that support mechanism unpredictably between otherwise related pages.</p>
<p>A key nuance is that WCAG 2.2 does <strong>not</strong> require every website to introduce one of these help mechanisms.</p>
<p>The criterion applies when qualifying help is already available and repeated across multiple pages in the same set.</p>
<p>The development implication is therefore mostly about consistency.</p>
<p>If users learn where help appears on one page, avoid making them search for it again on the next.</p>
<h2 id="heading-how-wcag-22-affects-authentication">How WCAG 2.2 Affects Authentication</h2>
<p>Authentication is another area that changed in WCAG 2.2.</p>
<p>Consider a login form that deliberately blocks paste:</p>
<pre><code class="language-javascript">passwordInput.addEventListener("paste", (event) =&gt; {
  event.preventDefault();
});
</code></pre>
<p>That may appear to encourage users to type a password manually, but it can also interfere with mechanisms that reduce the need to remember or transcribe credentials.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#accessible-authentication-minimum">Success Criterion 3.3.8 Accessible Authentication (Minimum)</a> addresses authentication steps that require cognitive function tests.</p>
<p>The Level AA requirement allows such tests when an accepted alternative or assistance mechanism is available. W3C specifically identifies password-manager support and copy-and-paste as mechanisms that can reduce the cognitive burden involved in authentication.</p>
<p>A conventional login form can allow these tools to work:</p>
<pre><code class="language-html">&lt;label for="username"&gt;
  Email address
&lt;/label&gt;

&lt;input
  id="username"
  name="username"
  type="email"
  autocomplete="username"
&gt;

&lt;label for="password"&gt;
  Password
&lt;/label&gt;

&lt;input
  id="password"
  name="password"
  type="password"
  autocomplete="current-password"
&gt;
</code></pre>
<p>It would be inaccurate to simplify this criterion to "WCAG 2.2 prohibits passwords". It does not.</p>
<p>A password is a cognitive function test, but the criterion permits it when the user has a mechanism that assists with completing that test, such as a password manager that can fill the field.</p>
<p>The same reasoning becomes relevant to multi-factor authentication.</p>
<p>If a process requires a user to read a code on one device and manually transcribe it to another, consider whether the authentication flow offers a path that avoids that cognitive burden. W3C's guidance explicitly discusses authentication processes with several steps and the need for an accessible path through them.</p>
<p>This is a good example of why the exact criterion matters more than a simplified accessibility checklist.</p>
<h2 id="heading-how-to-use-aria-without-replacing-html">How to Use ARIA Without Replacing HTML</h2>
<p>ARIA stands for <strong>Accessible Rich Internet Applications</strong>.</p>
<p>It provides roles, states, and properties that help web applications communicate information that may not otherwise be available to assistive technologies.</p>
<p>ARIA is useful. It's also easy to misuse.</p>
<p>Consider this example:</p>
<pre><code class="language-html">&lt;div role="button"&gt;
  Place order
&lt;/div&gt;
</code></pre>
<p>The <code>role</code> tells accessibility APIs that the element represents a button. It doesn't make the element behave like a button.</p>
<p>W3C <a href="https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/">ARIA Authoring Practices Guide</a> describes an ARIA role as a promise. When you use <code>role="button"</code>, you take responsibility for providing the expected keyboard and interaction behaviour yourself. ARIA doesn't cause the browser to add that behaviour automatically.</p>
<p>Where a native HTML element already exists, prefer it:</p>
<pre><code class="language-html">&lt;button type="button"&gt;
  Place order
&lt;/button&gt;
</code></pre>
<h3 id="heading-how-aria-can-communicate-state">How ARIA Can Communicate State</h3>
<p>ARIA becomes useful when HTML alone doesn't communicate enough about a component's current state.</p>
<p>Consider a disclosure control:</p>
<pre><code class="language-html">&lt;button
  id="account-options-trigger"
  type="button"
  aria-expanded="false"
  aria-controls="account-options"
&gt;
  Account options
&lt;/button&gt;

&lt;div id="account-options" hidden&gt;
  &lt;a href="/profile"&gt;Profile&lt;/a&gt;
  &lt;a href="/security"&gt;Security&lt;/a&gt;
&lt;/div&gt;
</code></pre>
<p>You can keep <code>aria-expanded</code> in sync with the visible state:</p>
<pre><code class="language-javascript">const trigger = document.querySelector(
  "#account-options-trigger"
);

const panel = document.querySelector(
  "#account-options"
);

trigger.addEventListener("click", () =&gt; {
  const isExpanded =
    trigger.getAttribute("aria-expanded") === "true";

  trigger.setAttribute(
    "aria-expanded",
    String(!isExpanded)
  );

  panel.hidden = isExpanded;
});
</code></pre>
<p>The JavaScript does two related things.</p>
<p>It changes whether the panel is hidden, and it updates the accessibility state exposed by the trigger.</p>
<p>If the panel opens visually but <code>aria-expanded</code> remains <code>false</code>, the interface now communicates two conflicting states.</p>
<p>This illustrates a useful ARIA rule: <strong>ARIA state must describe the interface that actually exists.</strong></p>
<p>For more complex patterns such as dialogs, comboboxes, tabs, menus, and grids, the W3C <a href="https://www.w3.org/WAI/ARIA/apg/">ARIA Authoring Practices Guide</a> provides documented interaction patterns and examples. W3C also makes clear that APG is implementation guidance rather than a normative accessibility standard.</p>
<h2 id="heading-how-to-make-dynamic-status-messages-accessible">How to Make Dynamic Status Messages Accessible</h2>
<p>Modern interfaces frequently update without loading a new page.</p>
<p>A user might save a profile and see:</p>
<pre><code class="language-text">Your settings were saved.
</code></pre>
<p>Or run a search and see:</p>
<pre><code class="language-text">18 results found.
</code></pre>
<p>A sighted user can often notice these updates without moving away from the current control.</p>
<p>Assistive technology also needs a programmatic way to identify relevant status messages.</p>
<p><a href="https://www.w3.org/TR/WCAG22/#status-messages">Success Criterion 4.1.3 Status Messages</a> requires qualifying status messages to be programmatically determinable so assistive technologies can present them without requiring the message itself to receive focus.</p>
<p>For a routine save confirmation, you can use <code>role="status"</code>:</p>
<pre><code class="language-html">&lt;button id="save-settings" type="button"&gt;
  Save settings
&lt;/button&gt;

&lt;p id="save-status" role="status"&gt;&lt;/p&gt;
</code></pre>
<p>Then update its content:</p>
<pre><code class="language-javascript">const saveButton = document.querySelector(
  "#save-settings"
);

const saveStatus = document.querySelector(
  "#save-status"
);

saveButton.addEventListener("click", () =&gt; {
  saveStatus.textContent =
    "Your settings were saved.";
});
</code></pre>
<p>The browser can expose that status change to supporting assistive technologies without moving keyboard focus away from the Save button.</p>
<p>Not every dynamic DOM change is a status message.</p>
<p>WCAG defines the term more narrowly. It includes information about the result or success of an action, an application's waiting state, the progress of a process, or the existence of errors when that update doesn't itself constitute a change of context.</p>
<p>Don't make every changing piece of content a live announcement. An excessively chatty interface can create a different usability problem.</p>
<p>Use status semantics for information users need to receive while continuing their current task.</p>
<h2 id="heading-how-to-test-your-website-for-accessibility">How to Test Your Website for Accessibility</h2>
<p>Accessibility testing works best as a combination of methods.</p>
<p>WCAG itself is designed to support testing through both automated tools and human evaluation. An automated scanner can identify many technical problems, but it can't reliably judge every accessibility requirement or determine whether an entire user journey makes sense.</p>
<h3 id="heading-how-to-start-with-automated-testing">How to Start with Automated Testing</h3>
<p>Automated tools are useful for repeatable technical checks.</p>
<p>They can identify many problems involving accessible names, some contrast failures, invalid ARIA usage, form relationships, and other machine-detectable conditions.</p>
<p>The limitation appears when correctness depends on meaning.</p>
<p>A tool can tell you that an image has an <code>alt</code> attribute. It can't always determine whether the text accurately communicates the purpose of the image.</p>
<p>Automation should therefore start the evaluation, not end it.</p>
<h3 id="heading-how-to-perform-keyboard-testing">How to Perform Keyboard Testing</h3>
<p>Open the page, put the mouse aside, and try to complete an actual task using only your keyboard.</p>
<p>Start with <code>Tab</code> to move forwards through interactive elements and <code>Shift + Tab</code> to move backwards.</p>
<p>Use <code>Enter</code> and <code>Space</code> to activate controls where appropriate. Custom widgets may also use arrow keys or <code>Escape</code> depending on their interaction pattern. The <a href="https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/">ARIA Authoring Practices keyboard guidance</a> documents expected behaviour for common widget patterns.</p>
<p>Don't simply press <code>Tab</code> a few times and stop.</p>
<p>For example, if you're testing a checkout flow:</p>
<ol>
<li><p>Navigate to the basket.</p>
</li>
<li><p>Change a quantity.</p>
</li>
<li><p>Continue to checkout.</p>
</li>
<li><p>Move through the form.</p>
</li>
<li><p>Submit it.</p>
</li>
<li><p>Correct an error.</p>
</li>
<li><p>Complete the process.</p>
</li>
</ol>
<p>As you do this, check whether you can reach and operate every required control, move away from every component, follow a sensible focus sequence, see where focus currently is, and avoid having focused content hidden by an overlay.</p>
<p>If something goes wrong, the relevant requirements include <a href="https://www.w3.org/TR/WCAG22/#keyboard">Success Criterion 2.1.1 Keyboard</a>, <a href="https://www.w3.org/TR/WCAG22/#no-keyboard-trap">Success Criterion 2.1.2 No Keyboard Trap</a>, <a href="https://www.w3.org/TR/WCAG22/#focus-order">Success Criterion 2.4.3 Focus Order</a>, <a href="https://www.w3.org/TR/WCAG22/#focus-visible">Success Criterion 2.4.7 Focus Visible</a>, and <a href="https://www.w3.org/TR/WCAG22/#focus-not-obscured-minimum">Success Criterion 2.4.11 Focus Not Obscured (Minimum)</a>.</p>
<h3 id="heading-how-to-test-zoom-resizing-and-reflow">How to Test Zoom, Resizing, and Reflow</h3>
<p>You can perform a basic zoom test directly in your browser.</p>
<p>In most browsers:</p>
<ul>
<li><p>use <code>Ctrl + +</code> on Windows or Linux</p>
</li>
<li><p>use <code>Cmd + +</code> on macOS</p>
</li>
<li><p>use <code>Ctrl/Cmd + 0</code> to return to the default zoom</p>
</li>
</ul>
<p>W3C's <a href="https://www.w3.org/WAI/test-evaluate/easy-checks/zoom/">Zoom Easy Check</a> suggests testing at 200%.</p>
<p>As you increase zoom, work through the page and look for:</p>
<ul>
<li><p>clipped text</p>
</li>
<li><p>overlapping elements</p>
</li>
<li><p>controls that disappear</p>
</li>
<li><p>navigation that stops working</p>
</li>
<li><p>content hidden behind other content</p>
</li>
<li><p>horizontal scrolling across ordinary page content</p>
</li>
</ul>
<p>Also use a narrow browser window or responsive browser tools to inspect how the content reflows.</p>
<p>This is to verify the behaviour discussed under <a href="https://www.w3.org/TR/WCAG22/#resize-text">Success Criterion 1.4.4 Resize Text</a> and <a href="https://www.w3.org/TR/WCAG22/#reflow">Success Criterion 1.4.10 Reflow</a>.</p>
<h3 id="heading-how-to-test-colour-and-contrast">How to Test Colour and Contrast</h3>
<p>Use a contrast checker or the colour information available in your browser developer tools to measure foreground and background combinations.</p>
<p>Do this for ordinary text as well as important non-text elements such as custom control borders, icons, and state indicators.</p>
<p>Then test colour-dependent information separately.</p>
<p>For example, if an error field turns red, temporarily ignore the colour change and ask whether another visible indication still communicates the error.</p>
<p>These checks correspond to <a href="https://www.w3.org/TR/WCAG22/#use-of-color">Success Criterion 1.4.1 Use of Color</a>, <a href="https://www.w3.org/TR/WCAG22/#contrast-minimum">Success Criterion 1.4.3 Contrast (Minimum)</a>, and <a href="https://www.w3.org/TR/WCAG22/#non-text-contrast">Success Criterion 1.4.11 Non-text Contrast</a>.</p>
<h3 id="heading-how-to-test-forms-manually">How to Test Forms Manually</h3>
<p>Don't test a form only with valid information. You should deliberately make mistakes to test as many cases as possible.</p>
<p>Leave a required field empty. Enter an incorrectly formatted email address. Submit a value the form should reject.</p>
<p>Then check whether you can:</p>
<ul>
<li><p>identify the field that has a problem</p>
</li>
<li><p>understand the error message</p>
</li>
<li><p>determine how to correct it</p>
</li>
<li><p>reach the error using the keyboard</p>
</li>
<li><p>correct the information and continue</p>
</li>
</ul>
<p>Also inspect form controls in your browser developer tools to confirm that visible labels and descriptions are associated with the correct fields.</p>
<p>These tests help you verify <a href="https://www.w3.org/TR/WCAG22/#error-identification">Success Criterion 3.3.1 Error Identification</a>, <a href="https://www.w3.org/TR/WCAG22/#labels-or-instructions">Success Criterion 3.3.2 Labels or Instructions</a>, and <a href="https://www.w3.org/TR/WCAG22/#error-suggestion">Success Criterion 3.3.3 Error Suggestion</a>.</p>
<h3 id="heading-how-to-test-pointer-and-dragging-interactions">How to Test Pointer and Dragging Interactions</h3>
<p>If your interface contains drag-and-drop, complete the action normally first. Then try to perform the same function without dragging.</p>
<p>For example, if you can drag a task into a new position, check whether another pointer-operated control lets you move it as well.</p>
<p>That gives you a practical test for <a href="https://www.w3.org/TR/WCAG22/#dragging-movements">Success Criterion 2.5.7 Dragging Movements</a>.</p>
<p>For small controls, use browser developer tools to inspect the rendered interactive area rather than judging only the visible icon.</p>
<p>Pay particular attention to close buttons, carousel controls, pagination items, icon buttons, and densely packed toolbars when checking <a href="https://www.w3.org/TR/WCAG22/#target-size-minimum">Success Criterion 2.5.8 Target Size (Minimum)</a>.</p>
<h3 id="heading-how-to-inspect-the-accessibility-tree">How to Inspect the Accessibility Tree</h3>
<p>Modern browser developer tools expose accessibility information associated with elements.</p>
<p>Inspect important controls and compare what the accessibility tree reports with what the interface shows.</p>
<p>A button might visually say <code>Search</code> while its accessible name says something completely different. A disclosure may look open while its <code>aria-expanded</code> state remains <code>false</code>.</p>
<p>Inspecting the accessibility tree helps expose these mismatches.</p>
<h3 id="heading-how-to-test-with-assistive-technology">How to Test with Assistive Technology</h3>
<p>When testing with a screen reader, focus on complete tasks.</p>
<p>For a form, navigate to the fields, identify their labels, enter incorrect information, submit it, locate and understand the errors, correct them, and confirm the successful state.</p>
<p>The question isn't simply:</p>
<blockquote>
<p><strong>Can the screen reader read this page?</strong></p>
</blockquote>
<p>The more useful question is:</p>
<blockquote>
<p><strong>Can the user complete the task and understand what happened?</strong></p>
</blockquote>
<p>Testing with disabled users can reveal additional usability barriers that automated and standards-based evaluation may not expose.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>WCAG becomes easier to understand when you connect its requirements to normal development decisions. The important shift is to stop treating accessibility as a final audit. Build it into the interface while you build everything else.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF to Grayscale Converter Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Many PDF documents contain colorful charts, presentations, marketing materials, scanned pages, or graphics that aren't always ideal for printing or archiving. In some cases, converting a document to g ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-to-grayscale-converter-javascript/</link>
                <guid isPermaLink="false">6a7f3af6a3acdf7f3110d046</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdfjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Fri, 14 Aug 2026 15:57:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c167a675-fcdd-4f28-9489-e42c3fe98d9d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Many PDF documents contain colorful charts, presentations, marketing materials, scanned pages, or graphics that aren't always ideal for printing or archiving.</p>
<p>In some cases, converting a document to grayscale reduces distractions, creates printer-friendly versions, lowers printing costs, or prepares files for black-and-white publishing.</p>
<p>A PDF to Grayscale Converter automates this process. Instead of editing every page manually, users can upload a PDF, choose how the grayscale conversion should be applied, preview the results, and download a newly generated document, all from within the browser.</p>
<p>In this tutorial, you'll build a browser-based PDF to Grayscale Converter using JavaScript. Users will be able to upload a PDF, preview every page, adjust the grayscale intensity, choose between multiple conversion modes, select which pages to process, generate a grayscale PDF, preview the final result, rename the output file, and download it without uploading their document to an external server.</p>
<p>We'll use PDF.js to render PDF pages, the HTML Canvas API to manipulate image pixels, and PDF-lib to generate the final downloadable PDF.</p>
<p>By the end of this tutorial, you'll have a complete client-side PDF processing application similar to the one available on All In One Tools.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-to-grayscale-converter-does-and-how-it-works">What This PDF to Grayscale Converter Does and How It Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-libraries-used">Libraries Used</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-building-the-conversion-settings">Building the Conversion Settings</a></p>
</li>
<li><p><a href="#heading-converting-pdf-pages-to-grayscale">Converting PDF Pages to Grayscale</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-to-grayscale-converter-works">Demo: How the PDF to Grayscale Converter Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-to-grayscale-converter-does-and-how-it-works">What This PDF to Grayscale Converter Does and How It Works</h2>
<p>A PDF to Grayscale Converter transforms colorful PDF pages into shades of gray while preserving the document's layout, page dimensions, text placement, and images. Instead of removing content, it recalculates the color of every pixel so the entire page appears in grayscale.</p>
<p>This is useful for creating printer-friendly documents, reducing color distractions, preparing files for monochrome printing, improving consistency across scanned documents, or producing black-and-white versions for review and archival purposes.</p>
<p>In this project, users can upload a PDF, browse through every page, adjust the grayscale intensity, choose between different conversion modes, decide whether all pages or only selected pages should be converted, generate a new grayscale PDF, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Behind the scenes, PDF.js renders each PDF page onto an HTML canvas. Once the page has been rendered, JavaScript reads the RGB values for every pixel and calculates a grayscale value using a luminance formula. The updated pixels are written back to the canvas before PDF-lib assembles all processed pages into a brand-new PDF.</p>
<p>A typical pixel contains four values:</p>
<pre><code class="language-javascript">const pixel = {
    red: 180,
    green: 95,
    blue: 40,
    alpha: 255
};
</code></pre>
<p>To convert that pixel into grayscale, JavaScript calculates a single luminance value and applies it equally to the red, green, and blue channels.</p>
<pre><code class="language-javascript">const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
</code></pre>
<p>The resulting pixel becomes:</p>
<pre><code class="language-javascript">pixel.red = gray;
pixel.green = gray;
pixel.blue = gray;
</code></pre>
<p>Repeating this process for every pixel on every selected page creates a new grayscale version of the original PDF while preserving the overall structure of the document.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing the conversion logic, let's create a simple project structure.</p>
<p>We'll build everything using HTML, CSS, and JavaScript, together with PDF.js, the Canvas API, and PDF-lib.</p>
<p>Our project structure looks like this:</p>
<pre><code class="language-text">pdf-to-grayscale/
│── index.html
│── style.css
│── script.js
│── pdf.worker.min.js
│── assets/
</code></pre>
<p>Keeping the HTML, styling, and JavaScript separate makes the project easier to maintain as more PDF features are added.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>The PDF to Grayscale Converter relies on three browser technologies that work together to render PDF pages, process image pixels, and generate a new downloadable document.</p>
<p><strong>PDF.js</strong> renders PDF pages directly inside the browser.</p>
<p>The <strong>HTML Canvas API</strong> provides access to every pixel so JavaScript can convert colors into grayscale.</p>
<p><strong>PDF-lib</strong> creates the final PDF after all selected pages have been processed.</p>
<p>Include the required libraries before loading your application.</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;
&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc = "pdf.worker.min.js";
</code></pre>
<p>Using a worker allows PDF rendering to happen in the background without freezing the browser interface.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application is divided into four main sections:</p>
<ul>
<li><p>Upload area</p>
</li>
<li><p>PDF preview</p>
</li>
<li><p>Conversion settings</p>
</li>
<li><p>Download section</p>
</li>
</ul>
<p>Create the basic page structure.</p>
<pre><code class="language-html">&lt;section id="uploadSection"&gt;&lt;/section&gt;
&lt;section id="previewSection" hidden&gt;&lt;/section&gt;
&lt;section id="settingsSection" hidden&gt;&lt;/section&gt;
&lt;section id="downloadSection" hidden&gt;&lt;/section&gt;
</code></pre>
<p>Only the upload area is visible when the page first loads. The remaining sections appear after a PDF has been successfully opened.</p>
<h3 id="heading-selecting-the-main-elements">Selecting the Main Elements</h3>
<p>Store references to the elements that will be used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection = document.getElementById("uploadSection");
const previewSection = document.getElementById("previewSection");
const settingsSection = document.getElementById("settingsSection");
const pdfCanvas = document.getElementById("pdfCanvas");
</code></pre>
<p>Using these references makes it easier to update the interface as users move through the conversion process.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload area accepts both drag-and-drop and manual file selection.</p>
<p>When a file is selected, first verify that it's a PDF.</p>
<pre><code class="language-javascript">async function uploadPdf(file) {
    if (!file || file.type !== "application/pdf") {
        alert("Please select a PDF file.");
        return;
    }

    await loadPdf(file);
}
</code></pre>
<p>Once validation succeeds, the document is loaded into memory for processing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/5f29ba44-afe7-4f2f-b685-2608b9b8ca55.png" alt="Upload area for selecting a PDF document." style="display:block;margin:0 auto" width="1016" height="603" loading="lazy">

<h3 id="heading-loading-the-pdf">Loading the PDF</h3>
<p>Convert the uploaded file into an ArrayBuffer before opening it with PDF.js.</p>
<pre><code class="language-javascript">async function loadPdf(file) {
    const bytes = await file.arrayBuffer();

    pdfDocument = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    currentPage = 1;
    renderPage(currentPage);
}
</code></pre>
<p>The loaded document is stored so every page can later be converted to grayscale.</p>
<h3 id="heading-rendering-pdf-pages">Rendering PDF Pages</h3>
<p>PDF.js renders one page at a time onto an HTML canvas.</p>
<p>Retrieve the page.</p>
<pre><code class="language-javascript">const page = await pdfDocument.getPage(currentPage);
</code></pre>
<p>Create the viewport.</p>
<pre><code class="language-javascript">const viewport = page.getViewport({
    scale: 1.5
});
</code></pre>
<p>Resize the canvas.</p>
<pre><code class="language-javascript">pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({
    canvasContext: pdfCanvas.getContext("2d"),
    viewport
}).promise;
</code></pre>
<p>Once rendering finishes, the selected page appears inside the preview area.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f1e5d376-b101-4116-9be6-68c4303eb398.png" alt="PDF page preview rendered with PDF.js." style="display:block;margin:0 auto" width="892" height="677" loading="lazy">

<h3 id="heading-navigating-between-pages">Navigating Between Pages</h3>
<p>Most PDF documents contain multiple pages, so users need simple navigation controls.</p>
<p>Track the current page.</p>
<pre><code class="language-javascript">let currentPage = 1;
let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton.addEventListener("click", async () =&gt; {
    if (currentPage &gt; 1) {
        currentPage--;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton.addEventListener("click", async () =&gt; {
    if (currentPage &lt; pdfDocument.numPages) {
        currentPage++;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Update the page indicator.</p>
<pre><code class="language-javascript">pageCounter.textContent = `Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>Users can now browse through the document before deciding how the grayscale conversion should be applied.</p>
<h2 id="heading-building-the-conversion-settings">Building the Conversion Settings</h2>
<p>After the PDF has been loaded and previewed, users can configure how the document should be converted to grayscale. The settings panel allows users to adjust the grayscale intensity, choose a conversion mode, decide which pages should be processed, and start the conversion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9fe91d30-e2b7-444d-8ada-f1606b172d1c.png" alt="PDF to Grayscale Converter settings panel showing intensity slider, conversion modes, and page selection options." style="display:block;margin:0 auto" width="908" height="633" loading="lazy">

<h3 id="heading-adjusting-grayscale-intensity">Adjusting Grayscale Intensity</h3>
<p>The intensity slider controls how strongly the grayscale effect is applied.</p>
<p>Lower values retain more of the original color, while higher values produce a true grayscale appearance.</p>
<p>Create the slider.</p>
<pre><code class="language-html">&lt;input type="range" id="grayIntensity" min="0" max="100" value="100"&gt;
</code></pre>
<p>Read the selected value.</p>
<pre><code class="language-javascript">const intensity = Number(document.getElementById("grayIntensity").value);
</code></pre>
<p>The selected intensity will later be used when calculating the final grayscale color.</p>
<h3 id="heading-choosing-the-conversion-mode">Choosing the Conversion Mode</h3>
<p>The tool provides multiple grayscale modes for different use cases.</p>
<p>Create the radio buttons.</p>
<pre><code class="language-html">&lt;input type="radio" name="mode" value="standard" checked&gt;
Standard Grayscale
&lt;input type="radio" name="mode" value="threshold"&gt;
Black &amp; White
&lt;input type="radio" name="mode" value="soft"&gt;
Soft Gray
</code></pre>
<p>Retrieve the selected mode.</p>
<pre><code class="language-javascript">const conversionMode = document.querySelector(
    'input[name="mode"]:checked'
).value;
</code></pre>
<p>Each mode uses a different algorithm when processing the canvas pixels.</p>
<h3 id="heading-selecting-the-pages">Selecting the Pages</h3>
<p>Users can convert either the entire document or only selected pages.</p>
<p>Create the page selection controls.</p>
<pre><code class="language-html">&lt;input type="radio" name="pages" value="all" checked&gt;
All Pages
&lt;input type="radio" name="pages" value="custom"&gt;
Specific Pages
&lt;input type="text" id="pageRange" placeholder="e.g., 1, 3-5, 10"&gt;
</code></pre>
<p>Read the selected option.</p>
<pre><code class="language-javascript">const applyMode = document.querySelector(
    'input[name="pages"]:checked'
).value;
</code></pre>
<p>Retrieve the custom page range.</p>
<pre><code class="language-javascript">const pageRange = document.getElementById("pageRange").value.trim();
</code></pre>
<p>When <strong>All Pages</strong> is selected, every page in the PDF is processed. Otherwise, only the pages specified by the user are converted.</p>
<h2 id="heading-converting-pdf-pages-to-grayscale">Converting PDF Pages to Grayscale</h2>
<p>Once the settings have been configured, users can begin the conversion.</p>
<p>Create the action button.</p>
<pre><code class="language-html">&lt;button id="convertPdf"&gt;Convert to Grayscale&lt;/button&gt;
</code></pre>
<p>Start the conversion.</p>
<pre><code class="language-javascript">convertButton.addEventListener("click", async () =&gt; {
    await convertPdf();
});
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/baede49d-a7e5-4ca5-9322-4b041bb6bfb4.png" alt="Convert to Grayscale button below the conversion settings." style="display:block;margin:0 auto" width="527" height="86" loading="lazy">

<h3 id="heading-starting-over">Starting Over</h3>
<p>Users can clear the current document and return the application to its initial state.</p>
<p>Create the reset button.</p>
<pre><code class="language-html">&lt;button id="resetTool"&gt;Start Over&lt;/button&gt;
</code></pre>
<p>Reset the application.</p>
<pre><code class="language-javascript">resetTool.addEventListener("click", () =&gt; {
    location.reload();
});
</code></pre>
<p>This removes the current PDF and restores the default settings so another document can be processed.</p>
<h3 id="heading-reading-canvas-pixels">Reading Canvas Pixels</h3>
<p>After a page has been rendered, retrieve its pixel data.</p>
<pre><code class="language-javascript">const imageData = context.getImageData(
    0,
    0,
    canvas.width,
    canvas.height
);
</code></pre>
<p>The pixel information is stored in an array.</p>
<pre><code class="language-javascript">const pixels = imageData.data;
</code></pre>
<p>Each pixel contains four values:</p>
<ul>
<li><p>Red</p>
</li>
<li><p>Green</p>
</li>
<li><p>Blue</p>
</li>
<li><p>Alpha</p>
</li>
</ul>
<p>We'll update the RGB values while leaving the alpha channel unchanged.</p>
<h3 id="heading-converting-colors-to-grayscale">Converting Colors to Grayscale</h3>
<p>The standard grayscale algorithm calculates a luminance value using the red, green, and blue channels.</p>
<p>Loop through every pixel.</p>
<pre><code class="language-javascript">for (let i = 0; i &lt; pixels.length; i += 4) {
    const gray =
        0.299 * pixels[i] +
        0.587 * pixels[i + 1] +
        0.114 * pixels[i + 2];

    pixels[i] = gray;
    pixels[i + 1] = gray;
    pixels[i + 2] = gray;
}
</code></pre>
<p>This formula produces a natural-looking grayscale image because it reflects how the human eye perceives brightness.</p>
<h3 id="heading-applying-the-selected-conversion-mode">Applying the Selected Conversion Mode</h3>
<p>Different conversion modes use different pixel calculations.</p>
<p>For example, the <strong>Black &amp; White (Threshold)</strong> mode converts each pixel into either pure black or pure white.</p>
<pre><code class="language-javascript">const threshold = 128;
const color = gray &gt;= threshold ? 255 : 0;
pixels[i] = color;
pixels[i + 1] = color;
pixels[i + 2] = color;
</code></pre>
<p>The <strong>Soft Gray</strong> mode blends the original color with the grayscale value to create a less aggressive effect.</p>
<pre><code class="language-javascript">const softGray =
    (gray * 0.6) +
    (pixels[i] * 0.4);

pixels[i] = softGray;
pixels[i + 1] = softGray;
pixels[i + 2] = softGray;
</code></pre>
<p>Once the selected mode has been applied, write the updated pixels back to the canvas.</p>
<pre><code class="language-javascript">context.putImageData(
    imageData,
    0,
    0
);
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a9d004ac-c519-463e-91a7-d98abfdeec62.png" alt="PDF page preview after applying the grayscale conversion." style="display:block;margin:0 auto" width="905" height="697" loading="lazy">

<h3 id="heading-processing-the-selected-pages">Processing the Selected Pages</h3>
<p>Instead of processing the entire document every time, convert only the pages selected by the user.</p>
<p>Loop through the page range.</p>
<pre><code class="language-javascript">for (let page = startPage; page &lt;= endPage; page++) {
    await processPage(page);
}
</code></pre>
<p>Each processed page is temporarily stored before the final PDF is created.</p>
<h2 id="heading-generating-the-final-pdf">Generating the Final PDF</h2>
<p>Create a new PDF document.</p>
<pre><code class="language-javascript">const outputPdf = await PDFLib.PDFDocument.create();
</code></pre>
<p>Convert the processed canvas into an image.</p>
<pre><code class="language-javascript">const imageBytes = await canvasToBytes(pdfCanvas);
</code></pre>
<p>Embed the image into the PDF.</p>
<pre><code class="language-javascript">const image = await outputPdf.embedPng(imageBytes);
</code></pre>
<p>Create a new page.</p>
<pre><code class="language-javascript">const page = outputPdf.addPage([
    image.width,
    image.height
]);
</code></pre>
<p>Draw the processed image.</p>
<pre><code class="language-javascript">page.drawImage(image, {
    x: 0,
    y: 0,
    width: image.width,
    height: image.height
});
</code></pre>
<p>Repeat this process for every selected page until the new grayscale document is complete.</p>
<h3 id="heading-saving-the-generated-pdf">Saving the Generated PDF</h3>
<p>After all pages have been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes = await outputPdf.save();
</code></pre>
<p>Create a downloadable PDF file.</p>
<pre><code class="language-javascript">generatedPdfBlob = new Blob([pdfBytes], {
    type: "application/pdf"
});
</code></pre>
<p>The grayscale PDF is now ready for preview, renaming, and downloading.</p>
<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Before downloading the converted document, it's useful to let users review the final output. This allows them to verify that the selected pages have been converted correctly and that the grayscale appearance meets their expectations.</p>
<p>Load the generated PDF using PDF.js.</p>
<pre><code class="language-javascript">let finalPdf = null;

async function showResult() {
    const bytes = await generatedPdfBlob.arrayBuffer();

    finalPdf = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    renderResultPage(1);
}
</code></pre>
<p>Render the selected page.</p>
<pre><code class="language-javascript">async function renderResultPage(pageNumber) {
    const page = await finalPdf.getPage(pageNumber);

    const viewport = page.getViewport({
        scale: 1.5
    });

    resultCanvas.width = viewport.width;
    resultCanvas.height = viewport.height;

    await page.render({
        canvasContext: resultCanvas.getContext("2d"),
        viewport
    }).promise;
}
</code></pre>
<p>Users can browse through every converted page before downloading the PDF.</p>
<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before saving the converted PDF, users can provide a custom filename.</p>
<p>Create the filename input.</p>
<pre><code class="language-html">&lt;input
    type="text"
    id="outputFilename"
    value="grayscale-document.pdf"
&gt;
</code></pre>
<p>Retrieve the filename.</p>
<pre><code class="language-javascript">function getFilename() {
    let filename = outputFilename.value.trim();

    if (!filename) {
        filename = "grayscale-document.pdf";
    }

    if (!filename.toLowerCase().endsWith(".pdf")) {
        filename += ".pdf";
    }

    return filename;
}
</code></pre>
<p>Display information about the generated PDF.</p>
<pre><code class="language-javascript">pageCount.textContent = `${finalPdf.numPages} Pages`;
fileSize.textContent = formatFileSize(generatedPdfBlob.size);
</code></pre>
<p>Download the completed PDF.</p>
<pre><code class="language-javascript">downloadButton.addEventListener("click", () =&gt; {
    const url = URL.createObjectURL(generatedPdfBlob);

    const link = document.createElement("a");
    link.href = url;
    link.download = getFilename();
    link.click();

    URL.revokeObjectURL(url);
});
</code></pre>
<p>Everything happens locally inside the browser, allowing users to keep their documents private throughout the conversion process.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/70faf03f-7d15-4c76-898d-088db8bb7448.png" alt="Download section showing the output filename, page count, file size, and Download button." style="display:block;margin:0 auto" width="948" height="435" loading="lazy">

<h2 id="heading-demo-how-the-pdf-to-grayscale-converter-works">Demo: How the PDF to Grayscale Converter Works</h2>
<p>Let's walk through the complete workflow.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9b6e9622-fe22-4e19-a6fd-23cb28defad9.png" alt="Upload area for selecting a PDF document." style="display:block;margin:0 auto" width="1016" height="603" loading="lazy">

<h3 id="heading-step-2-preview-the-document">Step 2: Preview the Document</h3>
<p>PDF.js renders the uploaded document page by page, allowing users to review the file before conversion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/93bd2249-88d1-4056-b3dd-c719e9853c70.png" alt="PDF ready to be converted" style="display:block;margin:0 auto" width="905" height="697" loading="lazy">

<h3 id="heading-step-3-configure-the-conversion">Step 3: Configure the Conversion</h3>
<p>Users adjust the grayscale intensity, choose a conversion mode, and decide whether to process all pages or only selected pages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/bcd8f197-ead0-4206-ac81-e03b85ff75c8.png" alt="Grayscale conversion settings panel." style="display:block;margin:0 auto" width="908" height="633" loading="lazy">

<h3 id="heading-step-4-convert-the-pdf">Step 4: Convert the PDF</h3>
<p>Click <strong>Convert to Grayscale</strong> to begin processing the selected pages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d34647d8-6df4-468b-b6db-1b2c5ebb3dbb.png" alt="Convert to Grayscale button with Start Over option." style="display:block;margin:0 auto" width="527" height="86" loading="lazy">

<h3 id="heading-step-5-review-the-converted-document">Step 5: Review the Converted Document</h3>
<p>After processing is complete, the application displays a preview of the generated grayscale PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8dbde4e8-447e-4704-bfd4-9a91244149e5.png" alt="Greyscale PDF preview after conversion" style="display:block;margin:0 auto" width="892" height="677" loading="lazy">

<h3 id="heading-step-6-rename-and-download">Step 6: Rename and Download</h3>
<p>Finally, users can rename the output file, review the page count and file size, and download the converted PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/3703ca08-3df4-4fd9-afbc-9a75b1b369bb.png" alt="Final download section with filename, page count, file size, and Download button." style="display:block;margin:0 auto" width="948" height="435" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Large PDF files require more processing time because every page must be rendered and converted. Processing only the selected pages helps improve performance.</p>
<pre><code class="language-javascript">for (let page = startPage; page &lt;= endPage; page++) {
    await processPage(page);
}
</code></pre>
<p>After downloading the file, release temporary resources to reduce memory usage.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(downloadUrl);
</code></pre>
<p>These simple optimizations help keep the converter responsive when working with large multi-page PDF documents.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>One common mistake is converting the same page multiple times without first rendering the original page again. Always start with the original PDF page before applying another grayscale conversion.</p>
<pre><code class="language-javascript">await renderPage(currentPage);
</code></pre>
<p>Another issue is allowing users to specify invalid page numbers.</p>
<pre><code class="language-javascript">if (pageNumber &lt; 1 || pageNumber &gt; pdfDocument.numPages) {
    return;
}
</code></pre>
<p>Finally, remember that higher output quality usually produces larger PDF files. Choosing the appropriate quality setting helps balance image clarity and file size.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF to Grayscale Converter using JavaScript.</p>
<p>You learned how to upload and preview PDF documents, render pages with PDF.js, convert colorful pages into grayscale using the Canvas API, process selected pages, generate a new PDF with PDF-lib, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Because all processing happens locally, users can convert PDF documents to grayscale without uploading sensitive files to an external server.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/pdf-to-grayscale-converter/?utm_source=chatgpt.com">PDF to Grayscale Converter</a>.</p>
<p>From here, you can extend the project with additional features such as sepia conversion, brightness and contrast controls, custom grayscale presets, batch PDF processing, or support for additional image filters.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create a Scalable KYC Onboarding Flow in React with Shadcn UI ]]>
                </title>
                <description>
                    <![CDATA[ Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-kyc-onboarding-flow-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a7e08ac157d6ad1bb83d9cf</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 18:10:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c86a7b7f-9199-499c-8f61-7a1fda09f519.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who they are.</p>
<p>That means collecting a business type, pulling in registration documents, and showing the user where their verification stands, all without making onboarding feel like a customs form.</p>
<p>This article breaks down a working three step KYC (Know Your Customer) flow built with Shadcn UI: a stepper for progress, a radio group for account type, a file upload zone for documents, and an alert for verification status. You'll see the actual component code, not a simplified stand-in, along with the reasoning behind each decision.</p>
<p>You can try the finished flow at <a href="http://onboarding-kyc-flow.vercel.app"><strong>onboarding-kyc-flow.vercel.app</strong></a>. Click through it once before reading on, as it makes the code below easier to follow. And it also comes in dark and light mode.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youre-building">What You're Building</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</a></p>
</li>
<li><p><a href="#heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</a></p>
</li>
<li><p><a href="#heading-step-1-account-type-with-a-radio-group">Step 1: Account Type with a Radio Group</a></p>
</li>
<li><p><a href="#heading-step-2-document-upload-with-drag-and-drop">Step 2: Document Upload with Drag and Drop</a></p>
</li>
<li><p><a href="#heading-step-3-verification-status-with-an-alert">Step 3: Verification Status with an Alert</a></p>
</li>
<li><p><a href="#heading-adding-a-stepper-to-the-flow">Adding a Stepper to the Flow</a></p>
</li>
<li><p><a href="#heading-small-details-that-make-it-feel-finished">Small Details that Make it Feel Finished</a></p>
</li>
<li><p><a href="#heading-accessibility-notes">Accessibility notes</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before working through this flow, you should be comfortable with React function components and hooks, specifically <code>useState</code>, <code>useRef</code>, and <code>useEffect</code>.</p>
<p>You should have:</p>
<ul>
<li><p>A Next.js project with the App Router and shadcn/ui already initialized, since this article doesn't cover that initial setup.</p>
</li>
<li><p>A v0 account is optional. You can also use Bolt or Lovable, which support the same shadcn MCP prompt feature.</p>
</li>
</ul>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>The flow has three steps:</p>
<ol>
<li><p><strong>Account type:</strong> The user picks Startup, Enterprise, or Government. This decision drives the rest of the experience. It's shown back to the user as a confirmation line, and would typically decide which workspace defaults get applied.</p>
</li>
<li><p><strong>Document upload:</strong> The user drags in a business registration document, a tax return, or a company registry export, in PDF or CSV format.</p>
</li>
<li><p><strong>Verification status:</strong> The user sees a live status: checking in progress, then either verified or an issue that needs attention.</p>
</li>
</ol>
<h2 id="heading-project-structure">Project Structure</h2>
<p>The project is a standard Next.js app with <a href="https://shadcnspace.com/"><strong>shadcn/ui</strong></a> already initialized. Here's the top-level layout:</p>
<pre><code class="language-javascript">onboarding-kyc-flow/
├── .vercel/
├── app/
├── components/
├── lib/
├── public/
├── .env.development.local
├── .gitignore
├── components.json
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── tsconfig.json
└── tsconfig.tsbuildinfo
</code></pre>
<p><code>components.json</code> is the file the shadcn CLI reads to know where your components live and which style and primitives you're using. <code>components/</code> holds the shared UI pieces (Alert, Badge, Button, Card, Progress, RadioGroup, Separator) that the flow is built from. <code>lib/utils.ts</code> provides the <code>cn</code> helper used throughout the flow to combine conditional class names. <code>app/</code> holds the page itself, shown in full below.</p>
<h2 id="heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</h2>
<p><a href="https://shadcnspace.com/components"><strong>Shadcn components</strong></a> aren't tied to one underlying primitive library. Most of the ecosystem defaults to Radix UI, but Base UI has become a solid alternative, and it's what this flow is built on.</p>
<p>The underlying primitive library can affect how a component behaves and how you work with it in your project. If you're pulling components from a set like Shadcn UI, check which primitive library it targets before mixing components from different sources.</p>
<p>Mixing Radix-based and Base UI-based components generally works, but it means using two different unstyled primitive libraries in the same project. You can <a href="https://shadcnspace.com/blog/radix-ui-vs-base-ui"><strong>compare Radix UI and Base UI here</strong></a>.</p>
<h2 id="heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</h2>
<p>An MCP (Model Context Protocol) server exposes a component library to an AI coding assistant as a set of callable tools. Instead of the assistant guessing at component names and props from training data, it queries the server for the real, current API.</p>
<p>This matters here specifically, since there are now several shadcn-style component sets with similar names and different props.</p>
<p>The Shadcn Components library publishes an MCP server for its free set, connected to v0 by following its <a href="https://shadcnspace.com/docs/getting-started/mcp-server-docs"><strong>getting started guide</strong></a>. The video below covers the connection step by step. The same generated output can also be copied into Lovable or Bolt through their copy prompt feature, so the workflow isn't locked to one AI builder.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/ymTlzbkvvPk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>The prompt used to scaffold this flow looked like this:</p>
<blockquote>
<p>Create an Enterprise SaaS Onboarding &amp; KYC Flow. Use free components of the shadcn space MCP server: shadcn alert, shadcn radio group, shadcn stepper, shadcn file upload. Only use free components, not pro ones, and list which free component was used for each part.</p>
<p>Step 1: Account Type (stepper) - radio group for Startup, Enterprise, or Government</p>
<p>Step 2: Upload Documents (stepper) - file upload for a business registration document</p>
<p>Step 3: Verification (stepper) - alert showing verification status</p>
</blockquote>
<p>This produces a working first draft fast. What follows is the result after cleaning that draft up: real state management, real validation, and states that a generated draft tends to skip.</p>
<h2 id="heading-step-1-account-type-with-a-radio-group">Step 1: Account Type with a Radio Group</h2>
<p>Account type is the first decision in the flow because it's the one most likely to affect what comes after it. Asking it early keeps the rest of the flow feeling relevant to the choice the user just made.</p>
<pre><code class="language-javascript">const tiers: { id: Tier; name: string; description: string; tag: string }[] = [
  { id: 'startup', name: 'Startup', description: 'For teams building and scaling fast', tag: 'Up to 25 seats' },
  { id: 'enterprise', name: 'Enterprise', description: 'For established teams with advanced needs', tag: 'Unlimited seats' },
  { id: 'government', name: 'Government', description: 'For public sector and regulated teams', tag: 'FedRAMP-ready' },
]
</code></pre>
<pre><code class="language-javascript">&lt;RadioGroup value={tier} onValueChange={(value) =&gt; setTier(value as Tier)} className="grid gap-3"&gt;
  &lt;fieldset className="contents"&gt;
    &lt;legend className="sr-only"&gt;Account type&lt;/legend&gt;
    {tiers.map((item) =&gt; (
      &lt;label
        key={item.id}
        htmlFor={item.id}
        className={cn(
          'flex cursor-pointer items-start gap-4 rounded-xl border p-4 transition-colors hover:border-primary/50',
          tier === item.id &amp;&amp; 'border-primary bg-primary/5'
        )}
      &gt;
        &lt;RadioGroupItem value={item.id} id={item.id} className="mt-0.5" /&gt;
        &lt;span className="flex flex-1 flex-col gap-1"&gt;
          &lt;span className="flex flex-wrap items-center gap-2 text-sm font-semibold"&gt;
            {item.name}
            {item.id === 'enterprise' &amp;&amp; &lt;Badge variant="secondary"&gt;Recommended&lt;/Badge&gt;}
          &lt;/span&gt;
          &lt;span className="text-sm text-muted-foreground"&gt;{item.description}&lt;/span&gt;
          &lt;span className="mt-1 font-mono text-[11px] uppercase tracking-wide text-muted-foreground"&gt;{item.tag}&lt;/span&gt;
        &lt;/span&gt;
      &lt;/label&gt;
    ))}
  &lt;/fieldset&gt;
&lt;/RadioGroup&gt;
</code></pre>
<p>Two things worth noticing here. The tier data lives in a plain array outside the component, so adding a fourth tier later is a one-line change, not a markup change. And the <code>fieldset</code> with a visually hidden (<code>sr-only</code>) legend groups the three options as one related choice for screen readers. Sighted users never see it, since the card title above already states "Choose your account type" visually.</p>
<p>This step uses a <a href="https://shadcnspace.com/components/radio-group"><strong>shadcn radio group</strong></a> rather than a select or checkboxes, since account type is a single, mutually exclusive choice, and a radio group is the only one of the three that makes both the options and the current selection visible at a glance.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/5ad2e892-88ae-4e6b-b83d-451ebe13dc74.png" alt="Step 1: Account type with a radio group" style="display:block;margin:0 auto" width="1902" height="946" loading="lazy">

<hr>
<h2 id="heading-step-2-document-upload-with-drag-and-drop">Step 2: Document Upload with Drag and Drop</h2>
<p>The upload zone needs to handle three states cleanly: nothing selected yet, a file selected and ready, and a rejected file with a specific reason why.</p>
<pre><code class="language-javascript">function FileUpload({ file, onFile, onRemove, error }: {
  file: File | null
  onFile: (file: File) =&gt; void
  onRemove: () =&gt; void
  error: string
}) {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null)
  const [dragging, setDragging] = useState(false)

  const accept = (candidate: File) =&gt; {
    if (candidate.type !== 'application/pdf' &amp;&amp; candidate.type !== 'text/csv' &amp;&amp; !candidate.name.toLowerCase().endsWith('.csv')) {
      return 'Upload a PDF or CSV file only.'
    }
    if (candidate.size &gt; 10 * 1024 * 1024) {
      return 'Files must be smaller than 10 MB.'
    }
    onFile(candidate)
    return ''
  }

  return (
    &lt;div className="flex flex-col gap-3"&gt;
      {!file ? (
        &lt;button
          type="button"
          className={cn(
            'group flex min-h-44 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/30 px-6 text-center transition-colors hover:border-primary hover:bg-primary/5',
            dragging &amp;&amp; 'border-primary bg-primary/10'
          )}
          onClick={() =&gt; inputRef.current?.click()}
          onDragOver={(event) =&gt; { event.preventDefault(); setDragging(true) }}
          onDragLeave={() =&gt; setDragging(false)}
          onDrop={(event) =&gt; {
            event.preventDefault()
            setDragging(false)
            const dropped = event.dataTransfer.files[0]
            if (dropped) accept(dropped)
          }}
        &gt;
          &lt;input
            ref={inputRef}
            className="sr-only"
            type="file"
            accept=".pdf,.csv,application/pdf,text/csv"
            onChange={(event) =&gt; {
              const selected = event.target.files?.[0]
              if (selected) accept(selected)
            }}
          /&gt;
          &lt;span className="mb-3 flex size-11 items-center justify-center rounded-lg border bg-background text-primary shadow-sm"&gt;
            &lt;UploadCloud className="size-5" aria-hidden="true" /&gt;
          &lt;/span&gt;
          &lt;span className="text-sm font-semibold"&gt;Drop your business document here&lt;/span&gt;
          &lt;span className="mt-1 text-xs text-muted-foreground"&gt;or click to browse · PDF or CSV · max 10 MB&lt;/span&gt;
        &lt;/button&gt;
      ) : (
        &lt;div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-4"&gt;
          &lt;span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"&gt;
            &lt;FileText className="size-5" aria-hidden="true" /&gt;
          &lt;/span&gt;
          &lt;div className="min-w-0 flex-1"&gt;
            &lt;p className="truncate text-sm font-semibold"&gt;{file.name}&lt;/p&gt;
            &lt;p className="text-xs text-muted-foreground"&gt;{(file.size / 1024 / 1024).toFixed(2)} MB · Ready to verify&lt;/p&gt;
          &lt;/div&gt;
          &lt;Badge variant="secondary" className="hidden sm:inline-flex"&gt;Uploaded&lt;/Badge&gt;
          &lt;Button type="button" variant="ghost" size="icon-sm" aria-label="Remove file" onClick={onRemove}&gt;
            &lt;X className="size-4" aria-hidden="true" /&gt;
          &lt;/Button&gt;
        &lt;/div&gt;
      )}
      {error &amp;&amp; (
        &lt;Alert variant="destructive"&gt;
          &lt;AlertCircle className="size-4" aria-hidden="true" /&gt;
          &lt;AlertTitle&gt;Unsupported document&lt;/AlertTitle&gt;
          &lt;AlertDescription&gt;{error}&lt;/AlertDescription&gt;
        &lt;/Alert&gt;
      )}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>accept</code> function is the whole validation layer, and it runs from two different places: the change handler on the hidden file input, and the drop handler on the drag zone.</p>
<p>Both paths call the same function, so a file dragged in gets the same validation checks as a file selected by clicking browse. It ensures that only <strong>PDF or CSV files</strong> are allowed, regardless of how the file is added.</p>
<p>This is where <a href="https://shadcnspace.com/components/file-upload"><strong>shadcn file upload</strong></a> earns its place over a plain <code>&lt;input type="file"&gt;</code>: the drag zone, the selected state, and the rejected state are all handled as one component instead of three separate pieces wired together by hand.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/959c806e-9e52-43f9-ad7f-c2855329ac5c.png" alt="Step 2: Document upload with drag and drop" style="display:block;margin:0 auto" width="1919" height="945" loading="lazy">

<h2 id="heading-step-3-verification-status-with-an-alert">Step 3: Verification Status with an Alert</h2>
<p>Verification isn't instant, so the interface needs to say clearly what's happening and what happens next, rather than showing a spinner with no explanation.</p>
<pre><code class="language-javascript">{verified ? (
  &lt;Alert className="border-primary/30 bg-primary/5"&gt;
    &lt;CheckCircle2 className="size-4 text-primary" aria-hidden="true" /&gt;
    &lt;AlertTitle&gt;Verification complete&lt;/AlertTitle&gt;
    &lt;AlertDescription&gt;
      Your {selectedTier.name.toLowerCase()} workspace is ready to configure.
    &lt;/AlertDescription&gt;
  &lt;/Alert&gt;
) : (
  &lt;&gt;
    &lt;Alert&gt;
      &lt;AlertCircle className="size-4" aria-hidden="true" /&gt;
      &lt;AlertTitle&gt;Verification in progress&lt;/AlertTitle&gt;
      &lt;AlertDescription&gt;
        This usually takes a few moments. You can keep this tab open while we finish.
      &lt;/AlertDescription&gt;
    &lt;/Alert&gt;
    &lt;div className="flex flex-col gap-3"&gt;
      &lt;div className="flex items-center justify-between text-sm"&gt;
        &lt;span className="font-medium"&gt;Checking business registry&lt;/span&gt;
        &lt;span className="font-mono text-xs text-muted-foreground"&gt;{checking ? '68%' : '100%'}&lt;/span&gt;
      &lt;/div&gt;
      &lt;Progress value={checking ? 68 : 100} /&gt;
      &lt;div className="flex items-center gap-2 text-xs text-muted-foreground"&gt;
        &lt;Building2 className="size-3.5" aria-hidden="true" /&gt; Matching company details and tax identifiers
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/&gt;
)}
</code></pre>
<p>Pairing the <a href="https://shadcnspace.com/components/alert"><strong>shadcn alert</strong></a> with a progress bar does two jobs at once: the alert states the current status in words, while the progress bar gives a rough sense of how much is left, without promising a specific time. Neither one alone tells the full story, the alert alone feels static, and a progress bar alone doesn't explain what's actually being checked.</p>
<p>Worth adding here, and easy to skip when a demo only shows the success path: a mismatch state, where the tax ID on the document doesn't match the company registry, deserves its own alert with a clear next step: contact support or re-upload a corrected document. It's not shown above, since the flow currently resolves to either checking or verified, but it's the state a production version of this flow would hit the most.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/55f02dfe-af23-4d07-aaaa-868e2a7fb834.png" alt="Step 3: Verification status with an alert" style="display:block;margin:0 auto" width="1919" height="946" loading="lazy">

<hr>
<h2 id="heading-adding-a-stepper-to-the-flow">Adding a Stepper to the Flow</h2>
<p>The stepper is the visual anchor of the whole flow. It's the piece that tells the user how much is left before the checking and account-type-selecting are done.</p>
<pre><code class="language-javascript">function Stepper({ current }: { current: Step }) {
  return (
    &lt;nav
      aria-label="Onboarding progress"
      className="grid grid-cols-[minmax(0,1fr)_minmax(2rem,5rem)_minmax(0,1fr)_minmax(2rem,5rem)_minmax(0,1fr)] items-start gap-0"
    &gt;
      {steps.map((step, index) =&gt; (
        &lt;div key={step.number} className="contents"&gt;
          &lt;div className="flex min-w-0 flex-col items-center text-center"&gt;
            &lt;div
              className={cn(
                'flex size-9 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
                current &gt; step.number
                  ? 'border-primary bg-primary text-primary-foreground'
                  : current === step.number
                  ? 'border-primary bg-primary/10 text-primary'
                  : 'border-border bg-background text-muted-foreground'
              )}
              aria-current={current === step.number ? 'step' : undefined}
            &gt;
              {current &gt; step.number ? &lt;Check className="size-4" aria-hidden="true" /&gt; : step.number}
            &lt;/div&gt;
            &lt;div className="mt-2 min-w-0"&gt;
              &lt;p className={cn('truncate text-sm font-semibold', current &gt;= step.number ? 'text-foreground' : 'text-muted-foreground')}&gt;
                {step.label}
              &lt;/p&gt;
              &lt;p className="mt-1 hidden text-xs leading-5 text-muted-foreground sm:block"&gt;{step.caption}&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          {index &lt; steps.length - 1 &amp;&amp; (
            &lt;div className={cn('mt-4 h-px w-full', current &gt; step.number ? 'bg-primary' : 'bg-border')} /&gt;
          )}
        &lt;/div&gt;
      ))}
    &lt;/nav&gt;
  )
}
</code></pre>
<p>The <code>current &gt; step.number</code> check keeps the entire stepper in sync with a single comparison. It determines the circle’s fill color, decides when the step number should be replaced by a checkmark, and controls whether the connecting line to the next step is filled.</p>
<p>This is important because the stepper only needs one piece of state, <code>step</code>, from the parent component. It doesn’t need to know why the user is on step 2, it only needs to know which step is currently active and update its visual state accordingly.</p>
<p>The "continue" logic that actually advances <code>step</code> lives outside the stepper itself:</p>
<pre><code class="language-javascript">const continueStep = () =&gt; {
  if (step === 1) setStep(2)
  else if (step === 2 &amp;&amp; file) {
    setStep(3)
    setChecking(true)
    window.setTimeout(() =&gt; {
      setChecking(false)
      setVerified(true)
    }, 1400)
  }
}
</code></pre>
<p>Keeping this in the page component, not inside the <a href="https://shadcnspace.com/components/stepper"><strong>shadcn stepper</strong></a> itself, is what keeps the stepper reusable. It only renders progress. Whether the user is allowed to move forward, a file is required on step 2, or whether nothing is required on step 1, is a decision for the flow around it to make.</p>
<h2 id="heading-small-details-that-make-it-feel-finished">Small Details that Make it Feel Finished</h2>
<p>A few things in this build are easy to skip but change how the flow feels in practice:</p>
<ul>
<li><p><strong>A dark mode toggle</strong> in the header, wired to a <code>darkMode</code> state that toggles a class on <code>document.documentElement</code>. It's small, but it means the flow doesn't fight a user's system theme preference.</p>
</li>
<li><p><strong>A security note</strong> in the sidebar, stating documents are encrypted and deleted after verification. This is copy, not code, but it answers the question a corporate user is quietest about and most worried by: what happens to the file after I upload it.</p>
</li>
<li><p><strong>A "Selected" confirmation line</strong> under the radio group, restating the chosen tier in plain text. A small detail, but it removes any doubt about what was actually selected before moving on.</p>
</li>
</ul>
<p>If you're looking to wrap a flow like this inside a full application shell, with navigation and a dashboard around it, the <a href="https://shadcnspace.com/admin-dashboard"><strong>Shadcn Dashboard</strong></a> starter uses the same component set. It's a reasonable base to extend from rather than building a shell from scratch.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<p><a class="embed-card" href="https://onboarding-kyc-flow.vercel.app/">https://onboarding-kyc-flow.vercel.app/</a></p>

<p>This project is open source, and you can easily download the zip and if you like. Please consider giving it a star.</p>
<ul>
<li><a href="https://github.com/vaibhavsudo/onboarding-kyc-flow"><strong>Github Repo</strong></a></li>
</ul>
<h2 id="heading-accessibility-notes">Accessibility notes</h2>
<ul>
<li><p>The stepper's <code>nav</code> element has an <code>aria-label</code>, and the current step carries <code>aria-current="step"</code>, so assistive technology can identify progress without relying on visual position alone.</p>
</li>
<li><p>The radio group sits inside a <code>fieldset</code> with a screen-reader-only <code>legend</code>, grouping the three account types as one decision.</p>
</li>
<li><p>Icons throughout (<code>Check</code>, <code>AlertCircle</code>, <code>UploadCloud</code>, and so on) carry <code>aria-hidden="true"</code>, since they're decorative next to text that already states the same information. This stops screen readers from announcing redundant icon labels.</p>
</li>
<li><p>The remove-file button has an explicit <code>aria-label</code>, since its visible content is an icon only, with no text.</p>
</li>
</ul>
<h2 id="heading-key-concepts-recap">Key Concepts Recap</h2>
<ul>
<li><p>Account type comes first because it's the one decision most likely to affect the rest of the flow, and it's kept in state at the page level, not inside the radio group itself.</p>
</li>
<li><p>File validation runs in a shared function used by both the drag-and-drop path and the click-to-browse path, so both paths apply the same PDF/CSV validation.</p>
</li>
<li><p>Verification status is communicated with both words (the alert) and a rough sense of progress (the progress bar), since either one alone leaves out part of the picture.</p>
</li>
<li><p>The stepper is a pure display component driven by a single <code>step</code> value from its parent. The logic for whether the user can advance lives outside it, not inside it.</p>
</li>
<li><p>Small, non-technical details (like a security note, a confirmation line, or a theme toggle) do as much for how finished a flow feels as any of the four core components.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>None of the four components in this flow are complicated individually. What makes a KYC flow work is the decisions around them: which choice comes first, where validation actually runs, and how honestly the interface talks to the user while something outside their control is being checked.</p>
<p>Whether the first draft comes from typing every line by hand or from scaffolding it with an MCP server and v0, that's the part worth spending time getting right before it ships.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a>, the free component set used in this flow</p>
</li>
<li><p><a href="https://shadcnspace.com/"><strong>ShadcnSpace</strong></a>, the base library these components extend</p>
</li>
<li><p><a href="https://shadcnspace.com/mcp"><strong>MCP server walkthrough</strong></a></p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current"><strong>MDN: ARIA current attribute</strong></a></p>
</li>
<li><p><a href="https://modelcontextprotocol.io/"><strong>Model Context Protocol specification</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Chrome Dino Game Works Under the Hood: A Tour of Chromium's Source Code ]]>
                </title>
                <description>
                    <![CDATA[ You've seen it a hundred times: the Wi-Fi drops, Chrome shrugs, and a little pixelated T-Rex appears, ready to sprint through a desert the moment you hit the spacebar. That tiny game, hidden behind th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-chrome-dino-game-works/</link>
                <guid isPermaLink="false">6a7e02796c61d1c629897f7c</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Game Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Oliinyk ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 17:44:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/dfca07f2-cf19-46c5-9c46-dad380bd0ed4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've seen it a hundred times: the Wi-Fi drops, Chrome shrugs, and a little pixelated T-Rex appears, ready to sprint through a desert the moment you hit the spacebar.</p>
<p>That tiny game, hidden behind the "No Internet" error since 2014, is played roughly 270 million times every month. Its internal codename at Google was "Project Bolan," a nod to Marc Bolan, frontman of the rock band T. Rex.</p>
<p>And because Chrome is built on the open-source Chromium project, the entire game – every constant, design decision, and hack – is sitting in public for anyone to read.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/5d2fb3c2-7ee8-47d8-90fa-de05a6cad06f.png" alt="The Chrome dino world, assembled from the original sprite sheet" style="display:block;margin:0 auto" width="1500" height="780" loading="lazy">

<p>So I read it. All of it. And it turns out this "simple" game is a small masterclass in game design: it quietly onboards you, refuses to kill you unfairly, animates a moon through seven phases, and has been shipping a typo to billions of devices for a decade.</p>
<p>In this article, we'll walk through the real source code and unpack how the dino game actually works. If you want the game open in a tab while you read, you don't need to kill your Wi-Fi. You can play the <a href="https://chromedino.com/">Dinosaur Game</a> online, and freeCodeCamp also has a <a href="https://www.freecodecamp.org/news/how-to-play-the-no-internet-google-chrome-dinosaur-game-both-online-and-offline/">guide to launching it on and offline</a>.</p>
<p>And the code itself lives in the Chromium tree, browsable at <a href="https://source.chromium.org/chromium/chromium/src/+/main:components/neterror/resources/dino_game/">source.chromium.org</a>. This is historically a single file of roughly 3,000 lines of dependency-free vanilla JavaScript, drawing on a plain <code>&lt;canvas&gt;</code>. No engine. No framework. Not even jQuery.</p>
<p>By the way, here's the entire game's artwork. It's one small PNG, with every sprite the code refers to by coordinates:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9f00025c-0c09-46b8-821b-8053393fc911.png" alt="The original sprite sheet, annotated: every visual in the game lives in this one image" style="display:block;margin:0 auto" width="2466" height="136" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-clock-why-the-game-runs-the-same-speed-everywhere">The Clock: Why the Game Runs the Same Speed Everywhere</a></p>
</li>
<li><p><a href="#heading-the-t-rex-four-constants-and-a-typo">The T-Rex: Four Constants and a Typo</a></p>
</li>
<li><p><a href="#heading-the-fairness-engine-how-the-game-refuses-to-cheat-you">The Fairness Engine: How the Game Refuses to Cheat You</a></p>
</li>
<li><p><a href="#heading-collision-detection-the-dino-is-six-rectangles">Collision Detection: The Dino Is Six Rectangles</a></p>
</li>
<li><p><a href="#heading-night-mode-and-the-seven-phase-moon">Night Mode and the Seven-Phase Moon</a></p>
</li>
<li><p><a href="#heading-the-small-delights-hiding-in-plain-sight">The Small Delights Hiding in Plain Sight</a></p>
</li>
<li><p><a href="#heading-what-you-can-steal-for-your-own-projects">What You Can Steal for Your Own Projects</a></p>
</li>
</ul>
<h2 id="heading-the-clock-why-the-game-runs-the-same-speed-everywhere">The Clock: Why the Game Runs the Same Speed Everywhere</h2>
<p>The first problem every game has to solve: browsers don't repaint at a fixed rate. A 144 Hz gaming monitor fires <code>requestAnimationFrame</code> 144 times a second. But a struggling laptop might manage 40. If you move things a fixed number of pixels per frame, your game literally runs 3× faster on better hardware.</p>
<p>The dino's solution is the standard one, executed cleanly. The game defines its speeds in pixels per frame <em>at an assumed 60 FPS</em>, then scales every movement by how much time actually passed:</p>
<pre><code class="language-javascript">this.msPerFrame = 1000 / FPS;
// ...in each update:
this.xPos -= Math.floor((currentSpeed * FPS / 1000) * deltaTime);
</code></pre>
<p>Every moving thing in the game from the dino's jump to the cacti, clouds, and even the moon, is multiplied by <code>deltaTime</code>. That's why your high score is comparable to your friend's, whatever machines you're both on.</p>
<p>If you take one engineering habit away from this article, take this one: <strong>never move anything by "per frame" amounts. Always scale by elapsed time.</strong></p>
<h2 id="heading-the-t-rex-four-constants-and-a-typo">The T-Rex: Four Constants and a Typo</h2>
<p>The dino's entire physical existence is defined by a handful of numbers in <code>Trex.config</code> and <code>Runner.config</code>:</p>
<pre><code class="language-javascript">GRAVITY: 0.6,
INIITAL_JUMP_VELOCITY: -10,
SPEED: 6,
ACCELERATION: 0.001,
MAX_SPEED: 13,
</code></pre>
<p>Yes, you read that right: <code>INIITAL_JUMP_VELOCITY</code>, with three I's. That misspelling shipped in Chrome, on billions of devices, and has survived for roughly a decade, because renaming it was never worth the risk. Let it comfort you the next time you find a typo in your own production code.</p>
<p>The units are pixels per frame at 60 FPS. Convert them and the physics becomes intuitive: gravity is 0.6 px/frame², jump velocity −10 px/frame. Run the math and the jump arc peaks at about 83 pixels roughly 0.28 seconds after takeoff. On a 150-pixel-tall playfield, that's more than half the screen.</p>
<p>The world starts scrolling at 6 px/frame (360 px/s) and gains 0.001 px/frame every frame until it hits the cap of 13, a little over twice the starting speed. That cap matters: it's the promise that the game gets <em>hard</em>, but never <em>impossible</em>.</p>
<p>But here's the detail most clones miss: <strong>the jump height is variable.</strong> Watch the code that runs when you release the spacebar:</p>
<pre><code class="language-javascript">endJump: function () {
    if (this.reachedMinHeight &amp;&amp;
        this.jumpVelocity &lt; this.config.DROP_VELOCITY) {
        this.jumpVelocity = this.config.DROP_VELOCITY;
    }
},
</code></pre>
<p>Tap the spacebar and the dino does a short hop, but hold it and the dino rides the full arc. Releasing the key early clamps the upward velocity, cutting the jump short (as long as a minimum height was reached, so you can't glitch yourself into a cactus). And if you press the Down arrow mid-air, <code>setSpeedDrop</code> multiplies the fall speed by 3, slamming the dino back to the ground for a fast recovery.</p>
<p>Two tiny mechanics, and suddenly the single-button game has an expressive skill ceiling: short hop, full jump, fast slam. That's why the top players' runs look nothing like yours.</p>
<h2 id="heading-the-fairness-engine-how-the-game-refuses-to-cheat-you">The Fairness Engine: How the Game Refuses to Cheat You</h2>
<p>This is my favorite part of the codebase, because none of it is visible. You can only <em>feel</em> it. Every obstacle in the game is declared with a small config, and the configs encode a set of fairness rules. Here's the small cactus:</p>
<pre><code class="language-javascript">{
    type: 'CACTUS_SMALL',
    width: 17,
    height: 35,
    multipleSpeed: 4,
    minGap: 120,
    minSpeed: 0,
    // ...
}
</code></pre>
<p>Let's unpack the rules hiding in there and elsewhere in the spawning code:</p>
<p><strong>Rule 1: The first three seconds are empty.</strong> <code>CLEAR_TIME: 3000</code> guarantees no obstacle spawns for the first three seconds of a run. That's silent onboarding: you get a moment to feel the controls before the game asks anything of you.</p>
<p><strong>Rule 2: Clusters are gated by speed.</strong> A cactus can spawn as a group of up to <code>MAX_OBSTACLE_LENGTH: 3</code>, but only when the current speed exceeds its <code>multipleSpeed</code> (4 for small cacti, 7 for large). Why? Because your jump <em>distance</em> grows with the world speed. The arc lasts a fixed time, so the faster the ground moves, the more ground you clear per jump.</p>
<p>Wide obstacles only appear once your jump is physically long enough to clear them. The game never generates a wall it knows you can't cross.</p>
<p><strong>Rule 3: Gaps scale with speed too.</strong> The gap after each obstacle is computed as roughly <code>obstacleWidth × speed + minGap × 0.6</code>, plus randomness. So the faster the game, the more room you're given to react. Difficulty comes from the speed itself, never from unfair spacing.</p>
<p><strong>Rule 4: No obstacle appears three times in a row.</strong> There's a function whose entire job is variety:</p>
<pre><code class="language-javascript">duplicateObstacleCheck: function (nextObstacleType) {
    var duplicateCount = 0;
    for (var i = 0; i &lt; this.obstacleHistory.length; i++) {
        duplicateCount = this.obstacleHistory[i] == nextObstacleType ?
            duplicateCount + 1 : 0;
    }
    return duplicateCount &gt;= Runner.config.MAX_OBSTACLE_DUPLICATION;
},
</code></pre>
<p>With <code>MAX_OBSTACLE_DUPLICATION: 2</code>, the spawner keeps a history and re-rolls if the same obstacle type would appear a third consecutive time. You've never noticed this rule, which is exactly the point. You'd have noticed its absence.</p>
<p><strong>Rule 5: The pterodactyl is a late-game boss.</strong> Its config says <code>minSpeed: 8.5</code>, meaning it can't appear at all until you're two-thirds of the way to max speed. It flies at one of three heights (so sometimes you jump it, sometimes you duck, and sometimes at 50 pixels you must decide), it never spawns in groups (<code>multipleSpeed: 999</code>), and it has its own <code>speedOffset: 0.8</code>, meaning each pterodactyl flies slightly faster or slower than the world scrolls. That last detail breaks your rhythm-based muscle memory precisely when you've gotten comfortable.</p>
<p>Together these rules are the answer to a question every game designer faces: how do you make a game <em>harder</em> without making it <em>unfair</em>? Players can feel the difference between "I lost because I was slow" and "I lost because the game cheated". And the dino, in ten years and trillions of runs, has never cheated anyone.</p>
<h2 id="heading-collision-detection-the-dino-is-six-rectangles">Collision Detection: The Dino Is Six Rectangles</h2>
<p>Naïve collision detection would wrap the dino in one bounding box and check overlap. But look at the dino: he has a snout sticking out, a tail, a gap under his chin. With a single box, a cactus grazing the empty air under his jaw would kill you, and it would feel terrible.</p>
<p>So the real dino is six boxes:</p>
<pre><code class="language-javascript">Trex.collisionBoxes = {
    RUNNING: [
        new CollisionBox(22, 0, 17, 16),   // head
        new CollisionBox(1, 18, 30, 9),    // torso
        new CollisionBox(10, 35, 14, 8),   // legs
        new CollisionBox(1, 24, 29, 5),
        new CollisionBox(5, 30, 21, 4),
        new CollisionBox(9, 34, 15, 4)
    ],
    DUCKING: [
        new CollisionBox(1, 18, 55, 25)    // one long low box
    ]
};
</code></pre>
<p>Six small rectangles that trace the dino's actual silhouette: head, torso, and a staircase of boxes down the belly and legs. When the dino ducks, the whole set is swapped for one long, low box.</p>
<p>The obstacles get the same treatment: a small cactus is three boxes tracing its trunk and arms, and the pterodactyl is <em>five</em>, following its wings and beak.</p>
<p>Here's what those boxes actually look like, drawn over the real sprites:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9a0dc54d-f59d-4427-8f18-cb42b2fa9092.png" alt="The real collision boxes from the source code, drawn over the sprites: 6 for the running T-Rex, 3 for a large cactus, 5 for the pterodactyl" style="display:block;margin:0 auto" width="1060" height="470" loading="lazy">

<p>The algorithm is a classic two-phase check: first a cheap outer-box test (do the overall rectangles even touch?), and only if that passes, the detailed loop comparing every dino box against every obstacle box. Fast in the common case, precise in the moment that matters. If a cactus needle visually passes through the notch under the dino's chin, you live, and the code agrees with your eyes.</p>
<p>This is the cheapest possible version of a technique that scales all the way up to fighting-game hitboxes and hurtboxes. The lesson generalizes: <strong>collision should match what the player sees, not what's convenient for the math.</strong></p>
<h2 id="heading-night-mode-and-the-seven-phase-moon">Night Mode and the Seven-Phase Moon</h2>
<p>Reach 700 points (<code>INVERT_DISTANCE: 700</code>) and the world inverts: dark sky, pale ground, stars. Twelve seconds later (<code>INVERT_FADE_DURATION: 12000</code>), day returns. Numerically it's just a class toggle plus a CSS-style inversion of the palette. But the charming part is what happens in the sky.</p>
<p>The moon isn't a static sprite. The sprite sheet contains it in seven versions, and the code cycles through them:</p>
<pre><code class="language-javascript">NightMode.phases = [140, 120, 100, 60, 40, 20, 0];
</code></pre>
<p>Those are x-offsets into the sprite sheet, from a thin crescent through the half moons to a full disc:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9823b7ea-c309-4db3-8e16-fcda9211f9af.png" alt="All seven moon phases, cropped straight from the sprite sheet at the offsets the code defines" style="display:block;margin:0 auto" width="968" height="290" loading="lazy">

<p>Every time night falls, the moon advances one phase. Stars drift at their own speed (<code>STAR_SPEED: 0.3</code>), slower than the ground, giving the night a whisper of parallax depth. Nobody needed a lunar calendar in a browser error page. Somebody built one anyway, and that somebody understood that details like this are the difference between a feature and a beloved thing.</p>
<h2 id="heading-the-small-delights-hiding-in-plain-sight">The Small Delights Hiding in Plain Sight</h2>
<p>A few more finds from the source that reward the attentive:</p>
<p><strong>The dino blinks.</strong> While the game waits for you to start, the idle dino blinks at randomized intervals. And there's a constant, <code>MAX_BLINK_COUNT: 3</code>, limiting how many times he'll do it. The blink delay itself is <code>Math.ceil(Math.random() * Trex.BLINK_TIMING)</code>. Someone at Google tuned the randomness of a dinosaur's eyelid.</p>
<p><strong>Your score isn't pixels.</strong> The distance meter multiplies actual pixels traveled by <code>COEFFICIENT: 0.025</code>. So a score of 100 means you've run 4,000 pixels. Every 100 points (<code>ACHIEVEMENT_DISTANCE: 100</code>), the score flashes at four beats per second – a tiny dopamine metronome that makes round numbers feel like events.</p>
<p><strong>The counter is theatrical about overflow.</strong> The display shows <code>MAX_DISTANCE_UNITS: 5</code> digits. Roll past 99,999 and the score visually resets. The internal counter keeps going, but the odometer effect stays, a deliberate homage to arcade cabinets.</p>
<p><strong>Mobile players get a handicap.</strong> <code>MOBILE_SPEED_COEFFICIENT: 1.2</code>: the game runs faster... wait, no: it adjusts for the smaller screens and touch latency so the experience feels equivalent. The point is that someone measured the difference between a thumb on glass and a finger on a spacebar, and encoded the answer in a constant.</p>
<p><strong>Restart is protected.</strong> After a crash there's a <code>GAMEOVER_CLEAR_TIME: 750</code>. For three-quarters of a second, your jump key won't restart the game. That's there because you <em>will</em> be hammering the spacebar when you die, and instantly restarting would rob you of the chance to see your score. A 750-millisecond act of mercy.</p>
<h2 id="heading-what-you-can-steal-for-your-own-projects">What You Can Steal for Your Own Projects</h2>
<p>The dino game is a masterclass precisely because its constraints were brutal: it had to be tiny, load instantly, run on everything from gaming rigs to $50 phones, and be understood by anyone in one second.</p>
<p>The techniques it uses under those constraints transfer to any project:</p>
<ul>
<li><p><strong>Scale by time, not frames.</strong> Delta-time movement is why the game is fair across hardware.</p>
</li>
<li><p><strong>Gate difficulty behind capability.</strong> Wide clusters appear only when the jump can clear them. Ask what the player <em>can do</em>, then spawn accordingly.</p>
</li>
<li><p><strong>Give the player an empty runway.</strong> Three quiet seconds teach the controls better than a tutorial screen.</p>
</li>
<li><p><strong>Enforce variety.</strong> A three-line history check prevents monotony the player would notice only as vague boredom.</p>
</li>
<li><p><strong>Make hitboxes honest.</strong> Six rectangles that match the silhouette beat one rectangle that betrays the player's eyes.</p>
</li>
<li><p><strong>Spend effort on invisible details.</strong> Blinking, moon phases, the restart grace period: none are necessary, but all are felt. Ten years on, the Chrome dino is proof that a great game doesn't need photorealistic graphics or a 100-gigabyte install. It just needs tight controls, fair rules, one button, and a moon that keeps its phases. Now you know exactly why it feels so good: because someone, line by line, made sure it would.</p>
</li>
</ul>
<p>Go read the source. It's one of the best free game design lessons on the internet, and it's been hiding behind your worst Wi-Fi days all along.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF Color Overlay Tool Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Sometimes you don't want to change the actual content of a PDF. You simply want to add a colored layer over part or all of the document. This can be useful for creating branded reports, adding colored ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-color-overlay-tool-javascript/</link>
                <guid isPermaLink="false">6a7d0b557863edaa10c98875</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdfjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 00:09:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9e841f91-5161-4559-816d-091e6dc39bcb.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Sometimes you don't want to change the actual content of a PDF. You simply want to add a colored layer over part or all of the document.</p>
<p>This can be useful for creating branded reports, adding colored backgrounds, highlighting printed copies, producing design mockups, applying watermarked color effects, or preparing documents for presentations.</p>
<p>A PDF Color Overlay Tool makes this possible by placing a semi-transparent color layer over PDF pages while preserving the original text, images, and layout beneath it.</p>
<p>Instead of manually editing every page in graphic design software, users can upload a PDF, choose an overlay color, adjust its transparency, select a blend mode, decide where it should appear, preview the result, and download the updated document.</p>
<p>In this tutorial, you'll build this tool using JavaScript. Users will be able to upload a PDF and perform all the actions just mentioned – all without sending the document to a server.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-color-overlay-tool-does-and-how-it-works">What This PDF Color Overlay Tool Does and How It Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-libraries-used">Libraries Used</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-building-the-overlay-settings">Building the Overlay Settings</a></p>
</li>
<li><p><a href="#heading-applying-color-overlays-to-pdf-pages">Applying Color Overlays to PDF Pages</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-color-overlay-tool-works">Demo: How the PDF Color Overlay Tool Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-color-overlay-tool-does-and-how-it-works">What This PDF Color Overlay Tool Does and How It Works</h2>
<p>A PDF Color Overlay Tool applies a colored layer on top of one or more pages while keeping the original PDF content visible underneath. Unlike a color inverter or grayscale converter, which permanently transform every pixel, a color overlay blends a selected color with the existing page using adjustable transparency and blend modes.</p>
<p>This makes it useful for creating branded documents, adding colored backgrounds, producing presentation-ready PDFs, highlighting sections, creating themed reports, or generating preview versions without modifying the original source document.</p>
<p>In this project, users can upload a PDF, preview every page, choose an overlay color using either a color picker or a hexadecimal value, adjust the overlay opacity, select a blend mode, choose where the overlay should appear, decide which pages should receive the effect, preview the updated document, and download the finished PDF directly from the browser.</p>
<p>Internally, <strong>PDF.js</strong> renders each page onto an HTML canvas. JavaScript then draws a colored rectangle over the rendered page using the selected transparency and blend mode. Once all selected pages have been processed, <strong>PDF-lib</strong> assembles the updated pages into a new downloadable PDF.</p>
<p>The overlay color is represented using a hexadecimal value.</p>
<pre><code class="language-javascript">const overlay = {
    color: "#667eea",
    opacity: 0.5
};
</code></pre>
<p>When drawing the overlay, JavaScript first sets the transparency level.</p>
<pre><code class="language-javascript">context.globalAlpha = overlay.opacity;
</code></pre>
<p>Next, the selected color is applied.</p>
<pre><code class="language-javascript">context.fillStyle = overlay.color;
</code></pre>
<p>Finally, the colored rectangle is drawn over the required area.</p>
<pre><code class="language-javascript">context.fillRect(0, 0, canvas.width, canvas.height);
</code></pre>
<p>Depending on the selected blend mode, the overlay can either gently tint the document, produce darker colors, create dramatic lighting effects, or generate completely different visual styles while preserving the original page underneath.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before implementing the overlay functionality, let's create a simple project structure.</p>
<p>We'll build the application using HTML, CSS, and JavaScript, together with PDF.js, the Canvas API, and PDF-lib.</p>
<p>Our project structure looks like this:</p>
<pre><code class="language-text">pdf-color-overlay/
│── index.html
│── style.css
│── script.js
│── pdf.worker.min.js
│── assets/
</code></pre>
<p>Separating the HTML, CSS, and JavaScript keeps the project organized and makes future enhancements easier to implement.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>Our PDF Color Overlay Tool relies on three browser technologies that work together to render PDF pages, apply color overlays, and generate a new downloadable document.</p>
<p><strong>PDF.js</strong> renders PDF pages directly inside the browser.</p>
<p>The <strong>HTML Canvas API</strong> draws the color overlay on top of each rendered page using transparency and blend modes.</p>
<p><strong>PDF-lib</strong> generates the final PDF after all selected pages have been processed.</p>
<p>Include the required libraries before loading your application:</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;
&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc = "pdf.worker.min.js";
</code></pre>
<p>Using a worker allows PDF rendering to happen in the background, keeping the interface responsive even when opening large PDF files.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application is divided into four main sections:</p>
<ul>
<li><p>Upload area</p>
</li>
<li><p>PDF preview</p>
</li>
<li><p>Overlay settings</p>
</li>
<li><p>Download section</p>
</li>
</ul>
<p>Create the basic layout.</p>
<pre><code class="language-html">&lt;section id="uploadSection"&gt;&lt;/section&gt;
&lt;section id="previewSection" hidden&gt;&lt;/section&gt;
&lt;section id="settingsSection" hidden&gt;&lt;/section&gt;
&lt;section id="downloadSection" hidden&gt;&lt;/section&gt;
</code></pre>
<p>Initially, only the upload area is visible. The remaining sections appear after a PDF has been successfully loaded.</p>
<h3 id="heading-selecting-the-main-elements">Selecting the Main Elements</h3>
<p>Store references to the elements used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection = document.getElementById("uploadSection");
const previewSection = document.getElementById("previewSection");
const settingsSection = document.getElementById("settingsSection");
const pdfCanvas = document.getElementById("pdfCanvas");
</code></pre>
<p>These references allow the application to update the interface without repeatedly searching the DOM.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload area supports both drag-and-drop and manual file selection.</p>
<p>Before loading the document, verify that the selected file is a PDF.</p>
<pre><code class="language-javascript">async function uploadPdf(file) {
    if (!file || file.type !== "application/pdf") {
        alert("Please select a PDF file.");
        return;
    }

    await loadPdf(file);
}
</code></pre>
<p>After validation, the PDF is loaded into memory for rendering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/4ae53817-f5eb-4dc2-86e4-19804098179b.png" alt="Upload area showing drag-and-drop support and Select PDF button." style="display:block;margin:0 auto" width="643" height="626" loading="lazy">

<h3 id="heading-loading-the-pdf">Loading the PDF</h3>
<p>Convert the uploaded file into an ArrayBuffer before opening it with PDF.js.</p>
<pre><code class="language-javascript">async function loadPdf(file) {
    const bytes = await file.arrayBuffer();
    pdfDocument = await pdfjsLib.getDocument({
        data: bytes
    }).promise;
    currentPage = 1;
    renderPage(currentPage);
}
</code></pre>
<p>Once the document has loaded successfully, the first page is rendered automatically.</p>
<h3 id="heading-rendering-pdf-pages">Rendering PDF Pages</h3>
<p>PDF.js renders one page at a time onto an HTML canvas.</p>
<p>Retrieve the selected page.</p>
<pre><code class="language-javascript">const page = await pdfDocument.getPage(currentPage);
</code></pre>
<p>Create the viewport.</p>
<pre><code class="language-javascript">const viewport = page.getViewport({
    scale: 1.5
});
</code></pre>
<p>Resize the canvas.</p>
<pre><code class="language-javascript">pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({
    canvasContext: pdfCanvas.getContext("2d"),
    viewport
}).promise;
</code></pre>
<p>After rendering completes, users can view the current page before applying any overlay effects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/2da41ef4-f637-48ef-a7a8-d23ea8e67069.png" alt="PDF preview rendered with PDF.js showing page navigation." style="display:block;margin:0 auto" width="653" height="476" loading="lazy">

<h3 id="heading-navigating-between-pages">Navigating Between Pages</h3>
<p>Most PDF documents contain multiple pages, so the application includes simple navigation controls.</p>
<p>Store the current page.</p>
<pre><code class="language-javascript">let currentPage = 1;
let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton.addEventListener("click", async () =&gt; {
    if (currentPage &gt; 1) {
        currentPage--;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton.addEventListener("click", async () =&gt; {
    if (currentPage &lt; pdfDocument.numPages) {
        currentPage++;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Update the page indicator.</p>
<pre><code class="language-javascript">pageCounter.textContent = `Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>Users can now browse through the uploaded PDF before deciding how the color overlay should be applied.</p>
<h2 id="heading-building-the-overlay-settings">Building the Overlay Settings</h2>
<p>After the PDF has been uploaded and previewed, users can configure how the color overlay should be applied. The settings panel lets users choose an overlay color, adjust its transparency, select a blend mode, specify where the overlay should appear, and decide which pages should receive the effect before generating the final PDF.</p>
<h3 id="heading-choosing-the-overlay-color">Choosing the Overlay Color</h3>
<p>The first setting allows users to choose the color that will be placed over the PDF.</p>
<p>The application supports both a color picker and direct hexadecimal input.</p>
<p>Create the color picker.</p>
<pre><code class="language-html">&lt;input type="color" id="overlayColor" value="#667eea"&gt;
</code></pre>
<p>Create the hexadecimal input.</p>
<pre><code class="language-html">&lt;input type="text" id="hexValue" value="#667eea"&gt;
</code></pre>
<p>Retrieve the selected color.</p>
<pre><code class="language-javascript">const overlayColor = document.getElementById("overlayColor").value;
</code></pre>
<p>If users enter a hexadecimal value manually, synchronize it with the color picker.</p>
<pre><code class="language-javascript">hexValue.addEventListener("input", () =&gt; {
    overlayColor.value = hexValue.value;
});
</code></pre>
<p>The selected color will later be drawn over the rendered PDF page.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/b39cdfad-9342-427a-a82d-1c405445457a.png" alt="Overlay color picker with hexadecimal color input." style="display:block;margin:0 auto" width="476" height="240" loading="lazy">

<h3 id="heading-adjusting-the-opacity">Adjusting the Opacity</h3>
<p>Opacity controls how transparent the overlay appears.</p>
<p>Lower values allow more of the original PDF to remain visible, while higher values create a stronger color effect.</p>
<p>Create the opacity slider.</p>
<pre><code class="language-html">&lt;input type="range" id="opacity" min="0" max="100" value="50"&gt;
</code></pre>
<p>Retrieve the selected value.</p>
<pre><code class="language-javascript">const opacity = Number(document.getElementById("opacity").value) / 100;
</code></pre>
<p>This value is later assigned to the canvas transparency before drawing the overlay.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c3d10506-f521-44bc-9c6f-f6508426e84a.png" alt="Opacity slider used to control overlay transparency." style="display:block;margin:0 auto" width="468" height="113" loading="lazy">

<h3 id="heading-selecting-the-blend-mode">Selecting the Blend Mode</h3>
<p>Blend modes determine how the overlay color interacts with the original PDF content.</p>
<p>Create the dropdown.</p>
<pre><code class="language-html">&lt;select id="blendMode"&gt;
    &lt;option value="source-over"&gt;Normal&lt;/option&gt;
    &lt;option value="multiply"&gt;Multiply&lt;/option&gt;
    &lt;option value="overlay"&gt;Overlay&lt;/option&gt;
    &lt;option value="soft-light"&gt;Soft Light&lt;/option&gt;
    &lt;option value="hard-light"&gt;Hard Light&lt;/option&gt;
    &lt;option value="difference"&gt;Difference&lt;/option&gt;
&lt;/select&gt;
</code></pre>
<p>Retrieve the selected blend mode.</p>
<pre><code class="language-javascript">const blendMode = document.getElementById("blendMode").value;
</code></pre>
<p>Each blend mode produces a different visual effect while preserving the document beneath the overlay.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a647f09a-ce11-45ec-b05e-3cb9a6e66a88.png" alt="Blend mode dropdown showing available overlay modes." style="display:block;margin:0 auto" width="485" height="337" loading="lazy">

<h3 id="heading-choosing-the-overlay-position">Choosing the Overlay Position</h3>
<p>The overlay doesn't always need to cover the entire page. Users can apply it only to specific regions if they want.</p>
<p>Create the available options.</p>
<pre><code class="language-html">&lt;input type="radio" name="position" value="full" checked&gt;
Full Page
&lt;input type="radio" name="position" value="header"&gt;
Header Only
&lt;input type="radio" name="position" value="footer"&gt;
Footer Only
</code></pre>
<p>Retrieve the selected position.</p>
<pre><code class="language-javascript">const position = document.querySelector('input[name="position"]:checked').value;
</code></pre>
<p>During processing, the application draws the overlay only inside the selected area.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ef1bbe82-1981-4df7-8c45-4dc2a97651c7.png" alt=" Overlay position options including Full Page, Header Only, and Footer Only. " style="display:block;margin:0 auto" width="223" height="197" loading="lazy">

<h3 id="heading-choosing-which-pages-to-process">Choosing Which Pages to Process</h3>
<p>Users can apply the overlay in several different ways:</p>
<ul>
<li><p>Current page only</p>
</li>
<li><p>Entire document</p>
</li>
<li><p>Separate overlay for every page</p>
</li>
<li><p>Specific pages</p>
</li>
</ul>
<p>Create the page selection controls.</p>
<pre><code class="language-html">&lt;input type="radio" name="pages" value="current" checked&gt;
Current page only
&lt;input type="radio" name="pages" value="all"&gt;
All pages
&lt;input type="radio" name="pages" value="separate"&gt;
Separate overlay per page
&lt;input type="radio" name="pages" value="custom"&gt;
Specific pages
&lt;input type="text" id="pageRange" placeholder="e.g., 1, 3-5, 10"&gt;
</code></pre>
<p>Retrieve the selected option.</p>
<pre><code class="language-javascript">const pageMode = document.querySelector('input[name="pages"]:checked').value;
</code></pre>
<p>Read the custom page range.</p>
<pre><code class="language-javascript">const pageRange = document.getElementById("pageRange").value.trim();
</code></pre>
<p>This flexibility allows users to apply different overlay strategies depending on the document.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/bc909a4b-d6f6-49bf-ba75-f5be6a445565.png" alt="Apply-to-pages options including Current Page, All Pages, Separate Overlay, and Specific Pages." style="display:block;margin:0 auto" width="477" height="302" loading="lazy">

<h3 id="heading-applying-the-overlay">Applying the Overlay</h3>
<p>Once all settings have been configured, users can begin processing the PDF.</p>
<p>Create the action button.</p>
<pre><code class="language-html">&lt;button id="applyOverlay"&gt;Apply Overlay&lt;/button&gt;
</code></pre>
<p>Start the processing workflow.</p>
<pre><code class="language-javascript">applyOverlay.addEventListener("click", async () =&gt; {
    await processOverlay();
});
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/89d69d51-9cbb-4640-b53c-0196b24b83c2.png" alt="Apply Overlay button." style="display:block;margin:0 auto" width="500" height="187" loading="lazy">

<h3 id="heading-starting-over">Starting Over</h3>
<p>Users can reset the application at any time and upload another document.</p>
<p>Create the reset button.</p>
<pre><code class="language-html">&lt;button id="resetTool"&gt;Start Over&lt;/button&gt;
</code></pre>
<p>Reset the tool.</p>
<pre><code class="language-javascript">resetTool.addEventListener("click", () =&gt; {
    location.reload();
});
</code></pre>
<p>The upload area becomes visible again, allowing another PDF to be processed without manually clearing every setting.</p>
<h2 id="heading-applying-color-overlays-to-pdf-pages">Applying Color Overlays to PDF Pages</h2>
<p>Now we'll build the main feature of the application: adding a colored overlay to PDF pages.</p>
<p>The process begins by rendering each selected PDF page onto an HTML canvas using PDF.js. JavaScript then draws a semi-transparent colored rectangle over the page using the selected blend mode. Once all selected pages have been processed, PDF-lib generates a new downloadable PDF.</p>
<h3 id="heading-applying-the-overlay-color">Applying the Overlay Color</h3>
<p>Before drawing anything, retrieve the selected color.</p>
<pre><code class="language-javascript">const overlayColor = document.getElementById("overlayColor").value;
</code></pre>
<p>Set the canvas fill color.</p>
<pre><code class="language-javascript">context.fillStyle = overlayColor;
</code></pre>
<p>This color will be drawn over the selected portion of each PDF page.</p>
<h3 id="heading-setting-the-overlay-transparency">Setting the Overlay Transparency</h3>
<p>Opacity determines how much of the original page remains visible beneath the overlay.</p>
<p>Apply the selected transparency.</p>
<pre><code class="language-javascript">context.globalAlpha = opacity;
</code></pre>
<p>A lower opacity produces a subtle tint, while higher values create a stronger visual effect.</p>
<h3 id="heading-applying-the-blend-mode">Applying the Blend Mode</h3>
<p>Canvas supports several compositing modes that determine how the overlay interacts with the existing page.</p>
<p>Assign the selected blend mode.</p>
<pre><code class="language-javascript">context.globalCompositeOperation = blendMode;
</code></pre>
<p>Some common modes include:</p>
<ul>
<li><p><strong>Normal</strong> – Places the color directly over the page.</p>
</li>
<li><p><strong>Multiply</strong> – Produces a darker appearance.</p>
</li>
<li><p><strong>Overlay</strong> – Increases overall contrast.</p>
</li>
<li><p><strong>Soft Light</strong> – Creates a gentle lighting effect.</p>
</li>
<li><p><strong>Hard Light</strong> – Produces a stronger contrast.</p>
</li>
<li><p><strong>Difference</strong> – Generates an inverted-style appearance based on color differences.</p>
</li>
</ul>
<h3 id="heading-drawing-the-overlay">Drawing the Overlay</h3>
<p>Once the color, opacity, and blend mode have been configured, draw the overlay on the canvas.</p>
<p>For a full-page overlay:</p>
<pre><code class="language-javascript">context.fillRect(0, 0, canvas.width, canvas.height);
</code></pre>
<p>If users choose <strong>Header Only</strong>, draw the rectangle across only the top section.</p>
<pre><code class="language-javascript">context.fillRect(0, 0, canvas.width, 120);
</code></pre>
<p>For <strong>Footer Only</strong>, draw the overlay near the bottom of the page.</p>
<pre><code class="language-javascript">context.fillRect(0, canvas.height - 120, canvas.width, 120);
</code></pre>
<p>These options allow different overlay styles without modifying the underlying PDF content.</p>
<h3 id="heading-processing-the-selected-pages">Processing the Selected Pages</h3>
<p>After configuring the overlay, process only the pages chosen by the user.</p>
<p>Loop through the selected pages.</p>
<pre><code class="language-javascript">for (let page = startPage; page &lt;= endPage; page++) {
    await processPage(page);
}
</code></pre>
<p>Each processed page is temporarily stored before creating the final document.</p>
<p>If <strong>Current Page Only</strong> is selected, only the active page is processed. If <strong>All Pages</strong> is selected, the overlay is applied to the complete document.</p>
<h2 id="heading-generating-the-final-pdf">Generating the Final PDF</h2>
<p>Create a new PDF document.</p>
<pre><code class="language-javascript">const outputPdf = await PDFLib.PDFDocument.create();
</code></pre>
<p>Convert the processed canvas into an image.</p>
<pre><code class="language-javascript">const imageBytes = await canvasToBytes(pdfCanvas);
</code></pre>
<p>Embed the image.</p>
<pre><code class="language-javascript">const image = await outputPdf.embedPng(imageBytes);
</code></pre>
<p>Create a new page.</p>
<pre><code class="language-javascript">const page = outputPdf.addPage([
    image.width,
    image.height
]);
</code></pre>
<p>Draw the processed image.</p>
<pre><code class="language-javascript">page.drawImage(image, {
    x: 0,
    y: 0,
    width: image.width,
    height: image.height
});
</code></pre>
<p>Repeat these steps until every selected page has been added to the new PDF.</p>
<h3 id="heading-saving-the-generated-pdf">Saving the Generated PDF</h3>
<p>Once all pages have been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes = await outputPdf.save();
</code></pre>
<p>Create a downloadable file.</p>
<pre><code class="language-javascript">generatedPdfBlob = new Blob([pdfBytes], {
    type: "application/pdf"
});
</code></pre>
<p>The new PDF containing the selected color overlays is now ready for preview.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/30c91f28-85a9-45a9-8195-14c568d3acad.png" alt="PDF preview after applying the selected color overlay." style="display:block;margin:0 auto" width="521" height="417" loading="lazy">

<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Before downloading the processed document, users should be able to review the final output. This makes it easy to verify that the selected color, opacity, blend mode, and page selection have been applied correctly.</p>
<p>Load the generated PDF.</p>
<pre><code class="language-javascript">let finalPdf = null;
async function showPreview() {
    const bytes = await generatedPdfBlob.arrayBuffer();
    finalPdf = await pdfjsLib.getDocument({
        data: bytes
    }).promise;
    renderFinalPage(1);
}
</code></pre>
<p>Render the selected page.</p>
<pre><code class="language-javascript">async function renderFinalPage(pageNumber) {
    const page = await finalPdf.getPage(pageNumber);

    const viewport = page.getViewport({
        scale: 1.5
    });

    previewCanvas.width = viewport.width;
    previewCanvas.height = viewport.height;

    await page.render({
        canvasContext: previewCanvas.getContext("2d"),
        viewport
    }).promise;
}
</code></pre>
<p>Users can navigate through the processed PDF before downloading it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9801192b-a454-4059-8e0b-cd9c3c12da8a.png" alt="Final PDF preview showing the applied color overlay before downloading." style="display:block;margin:0 auto" width="513" height="412" loading="lazy">

<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before saving the generated PDF, users can customize the output filename.</p>
<p>Create the filename input.</p>
<pre><code class="language-html">&lt;input type="text" id="outputFilename" value="color-overlay.pdf"&gt;
</code></pre>
<p>Retrieve the filename.</p>
<pre><code class="language-javascript">function getFilename() {
    let filename = outputFilename.value.trim();

    if (!filename) {
        filename = "color-overlay.pdf";
    }

    if (!filename.toLowerCase().endsWith(".pdf")) {
        filename += ".pdf";
    }

    return filename;
}
</code></pre>
<p>Display information about the generated PDF.</p>
<pre><code class="language-javascript">pageCount.textContent = `${finalPdf.numPages} Pages`;
fileSize.textContent = formatFileSize(generatedPdfBlob.size);
</code></pre>
<p>Download the completed document.</p>
<pre><code class="language-javascript">downloadButton.addEventListener("click", () =&gt; {
    const url = URL.createObjectURL(generatedPdfBlob);
    const link = document.createElement("a");

    link.href = url;
    link.download = getFilename();
    link.click();

    URL.revokeObjectURL(url);
});
</code></pre>
<p>Everything happens locally inside the browser, helping users keep their PDF files private.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9fc21f4d-ded1-49cb-be96-dd98897c767d.png" alt=" Download section showing the output filename, page count, file size, and Download button. " style="display:block;margin:0 auto" width="543" height="626" loading="lazy">

<h2 id="heading-demo-how-the-pdf-color-overlay-tool-works">Demo: How the PDF Color Overlay Tool Works</h2>
<p>Let's walk through the complete workflow.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c72deda9-c391-46eb-8983-89c67cf23fb7.png" alt="Upload area with drag-and-drop support and Select PDF button." style="display:block;margin:0 auto" width="643" height="626" loading="lazy">

<h3 id="heading-step-2-preview-the-document">Step 2: Preview the Document</h3>
<p>The uploaded PDF is rendered page by page, allowing users to review the document before applying any changes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/79bd1c6a-5a9e-4b3e-afe5-943b2220332f.png" alt="PDF preview with page navigation controls." style="display:block;margin:0 auto" width="653" height="476" loading="lazy">

<h3 id="heading-step-3-configure-the-overlay">Step 3: Configure the Overlay</h3>
<p>Users choose an overlay color, adjust the opacity, select a blend mode, choose the overlay position, and decide which pages should receive the effect.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/2aa9be33-b365-4a4d-aa82-328b77dc9e03.png" alt=" Overlay settings panel with color, opacity, blend mode, position, and page selection options." style="display:block;margin:0 auto" width="346" height="733" loading="lazy">

<h3 id="heading-step-4-apply-the-overlay">Step 4: Apply the Overlay</h3>
<p>Click <strong>Apply Overlay</strong> to process the selected pages using the chosen settings.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/507021b1-357d-42b6-b4f1-12c8f419d83b.png" alt="Apply Overlay button." style="display:block;margin:0 auto" width="500" height="187" loading="lazy">

<h3 id="heading-step-5-review-the-processed-pdf">Step 5: Review the Processed PDF</h3>
<p>The completed PDF appears in the preview window so users can verify the applied overlay before downloading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a3095b95-d52e-4890-938c-ad3c1a111147.png" alt="Final PDF preview after applying the selected overlay." style="display:block;margin:0 auto" width="513" height="412" loading="lazy">

<h3 id="heading-step-6-rename-and-download">Step 6: Rename and Download</h3>
<p>Finally, users rename the output file if needed, review the page count and file size, and download the generated PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/40faa4dc-67f9-4ff8-954c-bcab4e652196.png" alt="Download section with filename, page count, file size, and Download button." style="display:block;margin:0 auto" width="543" height="626" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Large PDF files can take longer to process because every selected page must be rendered and updated. Processing only the required pages helps improve performance.</p>
<pre><code class="language-javascript">for (const page of selectedPages) {
    await processPage(page);
}
</code></pre>
<p>After downloading the file, release temporary resources to reduce memory usage.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(downloadUrl);
</code></pre>
<p>These small optimizations help keep the application responsive when working with large multi-page PDF documents.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>One common mistake is applying multiple overlays without first restoring the original page. Always render a fresh copy of the PDF page before applying another overlay.</p>
<pre><code class="language-javascript">await renderPage(currentPage);
</code></pre>
<p>Another issue is forgetting to restore the default canvas state after changing the opacity or blend mode.</p>
<pre><code class="language-javascript">context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
</code></pre>
<p>Finally, using a very high opacity can completely hide the original PDF content. Choosing an appropriate transparency level usually produces a more balanced result.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF Color Overlay Tool using JavaScript.</p>
<p>You learned how to upload PDF documents, render pages with PDF.js, configure overlay colors, adjust opacity, apply blend modes, position overlays, process selected pages, generate a new PDF with PDF-lib, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Because the entire workflow runs locally, users can customize PDF documents without uploading sensitive files to an external server.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/pdf-color-overlay/">PDF Color Overlay Tool.</a></p>
<p>From here, you can extend the project with gradient overlays, custom overlay shapes, image overlays, reusable color presets, watermark templates, or additional PDF editing features for even greater flexibility.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF Filter Studio with JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ PDF editing isn't limited to adding signatures or merging documents. Sometimes you simply want to improve the appearance of a PDF by increasing brightness, boosting contrast, adding blur, converting i ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-filter-studio-javascript/</link>
                <guid isPermaLink="false">6a7c94929716b929062beeb1</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdfjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Wed, 12 Aug 2026 15:43:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b1fef698-f2b8-4fa3-a3e0-0cb0fc7e88a6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>PDF editing isn't limited to adding signatures or merging documents. Sometimes you simply want to improve the appearance of a PDF by increasing brightness, boosting contrast, adding blur, converting it to grayscale, or applying creative visual effects –&nbsp;all without opening Photoshop or installing desktop software.</p>
<p>In this tutorial, you'll build a browser-based PDF Filter Studio using JavaScript, PDF.js, the Canvas API, and PDF-lib. Users can upload a PDF, preview each page, stack multiple filter layers, apply preset effects, process selected pages, preview the final document, rename it, and download the edited PDF directly from the browser.</p>
<p>Since every step runs locally, the uploaded PDF never leaves the user's device, making the application both fast and privacy-friendly.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-filter-studio-does-and-how-it-works">What This PDF Filter Studio Does and How It Works</a></p>
</li>
<li><p><a href="#heading-why-build-a-pdf-filter-studio">Why Build a PDF Filter Studio?</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-libraries-used">Libraries Used</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-building-filter-layers">Building Filter Layers</a></p>
</li>
<li><p><a href="#heading-applying-filters-to-pdf-pages">Applying Filters to PDF Pages</a></p>
</li>
<li><p><a href="#heading-applying-preset-effects">Applying Preset Effects</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-filter-studio-works">Demo: How the PDF Filter Studio Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-filter-studio-does-and-how-it-works">What This PDF Filter Studio Does and How It Works</h2>
<p>Unlike tools that perform only a single operation, this PDF Filter Studio lets users combine multiple image adjustments before generating a new PDF.</p>
<p>After uploading a document, each page is rendered in the browser with <strong>PDF.js</strong> and displayed inside a preview window. Users can then create one or more filter layers, adjust values such as brightness, contrast, saturation, blur, opacity, grayscale, sepia, invert colors, or hue rotation, and instantly see how those settings affect the document.</p>
<p>For users who don't want to configure every adjustment manually, the application also includes preset effects like Grayscale, Sepia (Vintage), Invert (Negative), Sharpen, Glow, and Vignette. Once they've achieved the desired appearance, the selected filters are applied to the chosen pages, a new PDF is generated using <strong>PDF-lib</strong>, and the finished document can be reviewed, renamed, and downloaded without uploading files to any server.</p>
<p>This workflow provides a flexible way to enhance reports, presentations, scanned documents, marketing materials, and image-heavy PDFs directly inside the browser.</p>
<h2 id="heading-why-build-a-pdf-filter-studio">Why Build a PDF Filter Studio?</h2>
<p>Most online PDF editors focus on structural changes such as merging, splitting, rotating, or compressing documents. Very few allow users to enhance the visual appearance of PDF pages using adjustable image filters.</p>
<p>A browser-based PDF Filter Studio fills that gap by combining document processing with image editing. Instead of exporting PDF pages into image-editing software, applying effects, and recreating the document, users can complete the entire workflow in one place.</p>
<p>Building this project is also an excellent way to learn several important web development concepts, including rendering PDF pages with PDF.js, processing images with the Canvas API, creating reusable filter pipelines, managing multiple filter layers, working with dynamic user interfaces, and generating new PDF files using PDF-lib.</p>
<p>Because every operation happens locally inside the browser, documents remain private while delivering fast performance and eliminating the need for additional software installations</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create a project folder with the following structure:</p>
<pre><code class="language-text">pdf-filter-studio/
│── index.html
│── style.css
│── script.js
│── assets/
</code></pre>
<p>The project is intentionally simple.</p>
<ul>
<li><p><strong>index.html</strong> builds the application interface.</p>
</li>
<li><p><strong>style.css</strong> controls the layout and appearance.</p>
</li>
<li><p><strong>script.js</strong> manages PDF rendering, filter processing, and PDF generation.</p>
</li>
<li><p><strong>assets</strong> stores icons or other optional resources used by the application.</p>
</li>
</ul>
<p>Once the folder structure is ready, we'll import the required libraries, build the upload interface, render PDF pages, and begin creating the filter system.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>This PDF Filter Studio combines three browser technologies to upload PDF files, render pages, apply multiple visual filters, and generate a brand-new downloadable PDF.</p>
<p><strong>PDF.js</strong> is responsible for rendering PDF pages inside the browser.</p>
<p>The <strong>Canvas API</strong> applies brightness, contrast, blur, saturation, grayscale, sepia, invert, opacity, and hue rotation filters directly to each rendered page.</p>
<p>Finally, <strong>PDF-lib</strong> creates the edited PDF after all selected pages have been processed.</p>
<p>Include the required libraries before loading your JavaScript:</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;
&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc = "pdf.worker.min.js";
</code></pre>
<p>Using a worker keeps rendering smooth while JavaScript continues handling the user interface.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application is divided into four sections:</p>
<ul>
<li><p>Upload Area</p>
</li>
<li><p>PDF Preview</p>
</li>
<li><p>Filter Panel</p>
</li>
<li><p>Download Section</p>
</li>
</ul>
<p>Create the basic structure.</p>
<pre><code class="language-html">&lt;section id="uploadSection"&gt;&lt;/section&gt;
&lt;section id="previewSection" hidden&gt;&lt;/section&gt;
&lt;section id="filterSection" hidden&gt;&lt;/section&gt;
&lt;section id="downloadSection" hidden&gt;&lt;/section&gt;
</code></pre>
<p>Initially only the upload section is visible. Once a PDF is selected, the remaining sections automatically appear.</p>
<h3 id="heading-selecting-dom-elements">Selecting DOM Elements</h3>
<p>Store references to the elements used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection = document.getElementById("uploadSection");
const previewCanvas = document.getElementById("previewCanvas");
const previousButton = document.getElementById("previousPage");
const nextButton = document.getElementById("nextPage");
const rotateLeftButton = document.getElementById("rotateLeft");
const rotateRightButton = document.getElementById("rotateRight");
</code></pre>
<p>Keeping references at the beginning of the script makes the rest of the code cleaner and easier to maintain.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload area supports both drag-and-drop and manual file selection.</p>
<p>Before loading the document, verify that the selected file is actually a PDF.</p>
<pre><code class="language-javascript">async function uploadPdf(file) {
    if (!file || file.type !== "application/pdf") {
        alert("Please choose a PDF file.");
        return;
    }

    await loadPdf(file);
}
</code></pre>
<p>Once validation succeeds, the document is loaded into memory.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/cf81b585-7cfd-4e72-b3f4-c7725443d6bb.png" alt="Upload screen with drag-and-drop support and Select PDF button." style="display:block;margin:0 auto" width="1015" height="623" loading="lazy">

<h3 id="heading-loading-the-pdf">Loading the PDF</h3>
<p>Convert the uploaded file into an ArrayBuffer before opening it with PDF.js.</p>
<pre><code class="language-javascript">async function loadPdf(file) {
    const bytes = await file.arrayBuffer();
    pdfDocument = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    currentPage = 1;
    renderPage(currentPage);
}
</code></pre>
<p>After the document is loaded successfully, the first page is displayed automatically.</p>
<h3 id="heading-rendering-pdf-pages">Rendering PDF Pages</h3>
<p>Each page is rendered onto an HTML canvas.</p>
<p>Retrieve the selected page.</p>
<pre><code class="language-javascript">const page = await pdfDocument.getPage(currentPage);
</code></pre>
<p>Create a viewport.</p>
<pre><code class="language-javascript">const viewport = page.getViewport({
    scale: 1.5
});
</code></pre>
<p>Resize the canvas.</p>
<pre><code class="language-javascript">previewCanvas.width = viewport.width;
previewCanvas.height = viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({
    canvasContext: previewCanvas.getContext("2d"),
    viewport
}).promise;
</code></pre>
<p>Every page now appears exactly as it exists inside the original PDF.</p>
<h3 id="heading-navigating-between-pages">Navigating Between Pages</h3>
<p>Most PDF files contain multiple pages, so users need simple navigation controls.</p>
<p>Store the current page.</p>
<pre><code class="language-javascript">let currentPage = 1;
let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton.addEventListener("click", async () =&gt; {
    if (currentPage &gt; 1) {
        currentPage--;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton.addEventListener("click", async () =&gt; {
    if (currentPage &lt; pdfDocument.numPages) {
        currentPage++;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Update the page counter.</p>
<pre><code class="language-javascript">pageIndicator.textContent = `Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>Users can now browse through the document before adding filters.</p>
<h3 id="heading-rotating-the-preview">Rotating the Preview</h3>
<p>The application also includes preview rotation controls. Rotation affects only the preview, allowing users to inspect pages from different orientations before applying filters.</p>
<p>Store the current rotation angle.</p>
<pre><code class="language-javascript">let rotation = 0;
</code></pre>
<p>Rotate left.</p>
<pre><code class="language-javascript">rotateLeftButton.addEventListener("click", () =&gt; {
    rotation -= 90;
    renderPage(currentPage);
});
</code></pre>
<p>Rotate right.</p>
<pre><code class="language-javascript">rotateRightButton.addEventListener("click", () =&gt; {
    rotation += 90;
    renderPage(currentPage);
});
</code></pre>
<p>Apply the rotation when creating the viewport.</p>
<pre><code class="language-javascript">const viewport = page.getViewport({
    scale: 1.5,
    rotation
});
</code></pre>
<p>These controls improve the preview experience without modifying the original PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/b3aacd01-b54d-4a94-9ddb-8eb4dc37318a.png" alt=" PDF preview with previous/next page navigation and rotate left/right controls." style="display:block;margin:0 auto" width="1072" height="747" loading="lazy">

<h2 id="heading-building-the-filter-panel">Building the Filter Panel</h2>
<p>The Filter Panel is the core of the PDF Filter Studio. Instead of applying a single effect, users can build a stack of filter layers, combine multiple adjustments, and preview the result before generating the final PDF.</p>
<p>Each filter layer represents one image adjustment such as Brightness, Contrast, Saturation, Blur, Opacity, Grayscale, Sepia, Invert Colors, or Hue Rotate.</p>
<p>Users can also apply one-click preset effects like Grayscale, Sepia (Vintage), Invert (Negative), Sharpen, Glow, and Vignette.</p>
<h3 id="heading-creating-filter-layers">Creating Filter Layers</h3>
<p>Instead of hardcoding every adjustment, we'll store filters inside an array.</p>
<pre><code class="language-javascript">const filters = [];
</code></pre>
<p>Each filter contains its type and value.</p>
<pre><code class="language-javascript">filters.push({
    type: "brightness",
    value: 120
});
</code></pre>
<p>Using this structure allows users to combine multiple filters in any order.</p>
<h3 id="heading-adding-a-new-filter-layer">Adding a New Filter Layer</h3>
<p>Users first select a filter from the dropdown, then click <strong>Add</strong>.</p>
<p>Create the dropdown.</p>
<pre><code class="language-html">&lt;select id="filterType"&gt;
    &lt;option&gt;Brightness&lt;/option&gt;
    &lt;option&gt;Contrast&lt;/option&gt;
    &lt;option&gt;Saturation&lt;/option&gt;
    &lt;option&gt;Blur&lt;/option&gt;
    &lt;option&gt;Opacity&lt;/option&gt;
    &lt;option&gt;Grayscale&lt;/option&gt;
    &lt;option&gt;Sepia&lt;/option&gt;
    &lt;option&gt;Invert Colors&lt;/option&gt;
    &lt;option&gt;Hue Rotate&lt;/option&gt;
&lt;/select&gt;
</code></pre>
<p>Add the selected filter.</p>
<pre><code class="language-javascript">addButton.addEventListener("click", () =&gt; {
    filters.push({
        type: filterType.value,
        value: 100
    });

    renderFilters();
});
</code></pre>
<p>Each new filter immediately appears inside the Filter Layers panel.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f643ef4a-5b98-4a1c-990f-755b625c7bc1.png" alt="Dropdown menu used to add new filter layers." style="display:block;margin:0 auto" width="950" height="397" loading="lazy">

<h3 id="heading-displaying-filter-layers">Displaying Filter Layers</h3>
<p>Whenever a filter is added, rebuild the filter list.</p>
<pre><code class="language-javascript">function renderFilters() {
    filterContainer.innerHTML = "";

    filters.forEach(createFilterCard);
}
</code></pre>
<p>Each filter card contains:</p>
<ul>
<li><p>Filter name</p>
</li>
<li><p>Value slider</p>
</li>
<li><p>Current value</p>
</li>
<li><p>Delete button</p>
</li>
</ul>
<p>This design makes it easy to manage multiple adjustments.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c0c26067-5e95-4c44-bec6-827c2726c6bb.png" alt="Filter Layers section displaying an active Blur filter with its adjustment slider." style="display:block;margin:0 auto" width="976" height="273" loading="lazy">

<h3 id="heading-updating-filter-values">Updating Filter Values</h3>
<p>Each layer contains its own slider.</p>
<p>Example for Brightness:</p>
<pre><code class="language-javascript">slider.addEventListener("input", event =&gt; {
    filter.value = Number(event.target.value);
    updatePreview();
});
</code></pre>
<p>The preview refreshes immediately whenever a slider changes.</p>
<p>The same logic is reused for every filter type.</p>
<h3 id="heading-supporting-multiple-filter-types">Supporting Multiple Filter Types</h3>
<p>Different filters use different ranges.</p>
<pre><code class="language-javascript">const ranges = {
    brightness: [0, 200],
    contrast: [0, 200],
    saturation: [0, 200],
    blur: [0, 20],
    opacity: [0, 100],
    grayscale: [0, 100],
    sepia: [0, 100],
    invert: [0, 100],
    hue: [0, 360]
};
</code></pre>
<p>This allows every adjustment to use the most appropriate values.</p>
<h3 id="heading-preset-effects">Preset Effects</h3>
<p>Some users prefer one-click effects instead of manually creating filter layers.</p>
<p>The application includes several preset buttons.</p>
<pre><code class="language-html">&lt;button&gt;Grayscale&lt;/button&gt;
&lt;button&gt;Sepia&lt;/button&gt;
&lt;button&gt;Invert&lt;/button&gt;
&lt;button&gt;Sharpen&lt;/button&gt;
&lt;button&gt;Glow&lt;/button&gt;
&lt;button&gt;Vignette&lt;/button&gt;
</code></pre>
<p>Each preset simply creates one or more filter layers automatically.</p>
<p>For example, Grayscale:</p>
<pre><code class="language-javascript">function grayscalePreset() {
    filters.length = 0;

    filters.push({
        type: "grayscale",
        value: 100
    });

    updatePreview();
}
</code></pre>
<p>Users can still edit the generated layers afterwards.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6ba27a46-a341-43d9-9801-4e826685944d.png" alt="Preset Effects section with Grayscale selected." style="display:block;margin:0 auto" width="966" height="270" loading="lazy">

<h3 id="heading-applying-filters-to-specific-pages">Applying Filters to Specific Pages</h3>
<p>Not every page needs the same effect. Users can choose where filters should be applied.</p>
<p>Create the page options.</p>
<pre><code class="language-html">&lt;input type="radio" name="pages" value="current" checked&gt;
Current page only
&lt;input type="radio" name="pages" value="all"&gt;
All pages
&lt;input type="radio" name="pages" value="custom"&gt;
Specific pages
</code></pre>
<p>Retrieve the selected option.</p>
<pre><code class="language-javascript">const pageMode = document.querySelector('input[name="pages"]:checked').value;
</code></pre>
<p>If users choose <strong>Specific pages</strong>, read the page range.</p>
<pre><code class="language-javascript">const pageRange = document.getElementById("pageRange").value;
</code></pre>
<p>This allows users to edit only selected pages while leaving the rest unchanged.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1a0fe05f-f321-4029-bda8-be00908a17f4.png" alt="Apply to Pages section showing Current Page, All Pages, and Specific Pages." style="display:block;margin:0 auto" width="792" height="253" loading="lazy">

<h3 id="heading-removing-filter-layers">Removing Filter Layers</h3>
<p>Users can delete any filter before processing.</p>
<p>Create the delete function.</p>
<pre><code class="language-javascript">function removeFilter(index) {
    filters.splice(index, 1);
    renderFilters();
    updatePreview();
}
</code></pre>
<p>Removing a layer immediately updates the preview. This makes experimenting with different combinations quick and intuitive.</p>
<h2 id="heading-applying-filters-to-pdf-pages">Applying Filters to PDF Pages</h2>
<p>Now it's time to process the uploaded PDF.</p>
<p>Once users finish configuring their filter layers, the application renders every selected PDF page onto an HTML canvas, applies the configured filters in sequence, and generates a brand-new PDF using PDF-lib.</p>
<p>Unlike previous projects that applied only one effect, this Filter Studio supports <strong>multiple filter layers</strong>, allowing users to build their own image-processing pipeline.</p>
<h3 id="heading-building-the-canvas-filter-string">Building the Canvas Filter String</h3>
<p>The HTML Canvas API allows multiple filters to be combined into a single filter string.</p>
<p>Start with an empty string.</p>
<pre><code class="language-javascript">let filterString = "";
</code></pre>
<p>Loop through every filter layer.</p>
<pre><code class="language-javascript">filters.forEach(filter =&gt; {
    filterString += `${filter.type}
(${filter.value})
`;
});
</code></pre>
<p>Assign the completed filter string.</p>
<pre><code class="language-javascript">context.filter = filterString.trim();
</code></pre>
<p>Every active layer is now combined before rendering the page.</p>
<h3 id="heading-drawing-the-filtered-page">Drawing the Filtered Page</h3>
<p>Once the filter string has been created, redraw the rendered PDF page.</p>
<pre><code class="language-javascript">context.drawImage(pdfCanvas, 0, 0);
</code></pre>
<p>The canvas now contains the filtered version of the page.</p>
<p>This approach allows several adjustments to be applied in a single rendering pass.</p>
<h3 id="heading-applying-multiple-filter-layers">Applying Multiple Filter Layers</h3>
<p>Since every adjustment is stored inside the <strong>filters</strong> array, users can combine effects however they like.</p>
<p>For example:</p>
<pre><code class="language-javascript">filters = [
    {
        type: "brightness",
        value: "130%"
    },
    {
        type: "contrast",
        value: "115%"
    },
    {
        type: "blur",
        value: "5px"
    }
];
</code></pre>
<p>These filters are automatically combined into one Canvas filter string before rendering.</p>
<p>This makes the application flexible while keeping the code simple.</p>
<h2 id="heading-applying-preset-effects">Applying Preset Effects</h2>
<p>Preset buttons simply replace the current filter list with predefined values.</p>
<p>Example for the Sepia preset:</p>
<pre><code class="language-javascript">filters = [
    {
        type: "sepia",
        value: "100%"
    }
];

updatePreview();
</code></pre>
<p>Likewise, the <strong>Glow</strong> preset may combine brightness and blur.</p>
<pre><code class="language-javascript">filters = [
    {
        type: "brightness",
        value: "125%"
    },
    {
        type: "blur",
        value: "2px"
    }
];
</code></pre>
<p>Preset effects save users time while still allowing manual adjustments afterward.</p>
<h3 id="heading-processing-selected-pages">Processing Selected Pages</h3>
<p>Once the filters are ready, process only the pages selected by the user.</p>
<pre><code class="language-javascript">for (const page of selectedPages) {
    await processPage(page);
}
</code></pre>
<p>If <strong>Current Page Only</strong> is selected, only the active page is processed.</p>
<p>If <strong>All Pages</strong> is selected, the loop processes every page in the document.</p>
<p>If users specify custom page numbers, only those pages are filtered.</p>
<h3 id="heading-applying-filters">Applying Filters</h3>
<p>Users begin processing by clicking <strong>Apply Filters to PDF</strong>.</p>
<p>Create the action button.</p>
<pre><code class="language-html">&lt;button id="applyFilters"&gt;Apply Filters to PDF&lt;/button&gt;
</code></pre>
<p>Start processing.</p>
<pre><code class="language-javascript">applyFilters.addEventListener("click", async () =&gt; {
    await generatePdf();
});
</code></pre>
<p>While processing, display a loading indicator so users know the application is working.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c8454b4a-218b-432b-8ed8-203eefaeda7b.png" alt="Apply Filters to PDF button." style="display:block;margin:0 auto" width="297" height="95" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/21a493e5-2557-4c34-a33a-ac862edbcc32.png" alt="Processing indicator displayed while filters are being applied." style="display:block;margin:0 auto" width="671" height="103" loading="lazy">

<h2 id="heading-generating-the-final-pdf">Generating the Final PDF</h2>
<p>Create a new PDF document.</p>
<pre><code class="language-javascript">const outputPdf = await PDFLib.PDFDocument.create();
</code></pre>
<p>Convert the filtered canvas into an image.</p>
<pre><code class="language-javascript">const imageBytes = await canvasToBytes(previewCanvas);
</code></pre>
<p>Embed the image.</p>
<pre><code class="language-javascript">const image = await outputPdf.embedPng(imageBytes);
</code></pre>
<p>Create a page.</p>
<pre><code class="language-javascript">const page = outputPdf.addPage([
    image.width,
    image.height
]);
</code></pre>
<p>Draw the processed image.</p>
<pre><code class="language-javascript">page.drawImage(image, {
    x: 0,
    y: 0,
    width: image.width,
    height: image.height
});
</code></pre>
<p>Repeat this process until every selected page has been added to the new PDF.</p>
<h3 id="heading-saving-the-pdf">Saving the PDF</h3>
<p>Once all pages have been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes = await outputPdf.save();
</code></pre>
<p>Create the downloadable file.</p>
<pre><code class="language-javascript">generatedPdf = new Blob([pdfBytes], {
    type: "application/pdf"
});
</code></pre>
<p>The generated PDF is now ready for preview and download.</p>
<h3 id="heading-previewing-the-filtered-document">Previewing the Filtered Document</h3>
<p>Before downloading, the application displays the processed PDF so users can verify the applied filters.</p>
<p>The preview includes page navigation and rotation controls, making it easy to inspect the final result before saving the file.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/dd6aa1c4-eb12-4166-ba24-24559b3abd7a.png" alt="Preview of the processed PDF after applying filter layers." style="display:block;margin:0 auto" width="693" height="527" loading="lazy">

<h3 id="heading-starting-over">Starting Over</h3>
<p>If users want to process another document, they can reset the application with a single click.</p>
<pre><code class="language-javascript">resetButton.addEventListener("click", () =&gt; {
    location.reload();
});
</code></pre>
<p>Reloading clears the uploaded PDF, removes all filter layers, resets preset effects, and returns the application to its initial upload screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/18d979c1-0d2a-4b16-b1bf-4386cd8a0745.png" alt=" Start Over button for resetting the PDF Filter Studio." style="display:block;margin:0 auto" width="187" height="77" loading="lazy">

<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Before downloading the edited document, it's helpful to let users review the processed PDF. This gives them an opportunity to verify that every selected filter has been applied correctly and make adjustments if necessary.</p>
<p>Load the generated PDF.</p>
<pre><code class="language-javascript">let finalPdf = null;

async function showPreview() {
    const bytes = await generatedPdf.arrayBuffer();

    finalPdf = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    renderPreviewPage(1);
}
</code></pre>
<p>Render the selected page.</p>
<pre><code class="language-javascript">async function renderPreviewPage(pageNumber) {
    const page = await finalPdf.getPage(pageNumber);
    const viewport = page.getViewport({
        scale: 1.5
    });

    previewCanvas.width = viewport.width;
    previewCanvas.height = viewport.height;

    await page.render({
        canvasContext: previewCanvas.getContext("2d"),
        viewport
    }).promise;
}
</code></pre>
<p>Users can browse every processed page before downloading the finished PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/955f9ca4-71fa-48f2-a998-c61eb7d1343b.png" alt="Preview of the processed PDF after applying all selected filter layers." style="display:block;margin:0 auto" width="693" height="527" loading="lazy">

<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before saving the generated PDF, users can choose a custom filename.</p>
<p>Create the filename input.</p>
<pre><code class="language-html">&lt;input type="text" id="outputFilename" value="filtered-document.pdf"&gt;
</code></pre>
<p>Retrieve the filename.</p>
<pre><code class="language-javascript">function getFilename() {
    let filename = outputFilename.value.trim();

    if (!filename) {
        filename = "filtered-document.pdf";
    }

    if (!filename.toLowerCase().endsWith(".pdf")) {
        filename += ".pdf";
    }

    return filename;
}
</code></pre>
<p>Display useful information about the generated PDF.</p>
<pre><code class="language-javascript">pageCount.textContent = `${finalPdf.numPages} Pages`;
fileSize.textContent = formatFileSize(generatedPdf.size);
</code></pre>
<p>Download the document.</p>
<pre><code class="language-javascript">downloadButton.addEventListener("click", () =&gt; {
    const url = URL.createObjectURL(generatedPdf);
    const link = document.createElement("a");

    link.href = url;
    link.download = getFilename();
    link.click();

    URL.revokeObjectURL(url);
});
</code></pre>
<p>Everything happens locally inside the browser, helping protect users' documents and reducing upload time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d8765500-e3b8-4653-8422-1c515aa03a3c.png" alt="Download section showing filename, page count, file size, rename option, and Download PDF button." style="display:block;margin:0 auto" width="682" height="517" loading="lazy">

<h2 id="heading-demo-how-the-pdf-filter-studio-works">Demo: How the PDF Filter Studio Works</h2>
<p>The complete workflow consists of just a few steps.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/851d6e87-f454-4ae2-9695-d82818879894.png" alt="Upload area with drag-and-drop support." style="display:block;margin:0 auto" width="1015" height="623" loading="lazy">

<h3 id="heading-step-2-preview-the-pdf">Step 2: Preview the PDF</h3>
<p>The uploaded document is rendered page by page using <strong>PDF.js</strong>. Users can navigate through the document and rotate pages before editing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e7288e0c-b363-4e95-939f-6490ef501d70.png" alt="PDF preview with page navigation and rotation controls." style="display:block;margin:0 auto" width="1072" height="747" loading="lazy">

<h3 id="heading-step-3-configure-the-filters">Step 3: Configure the Filters</h3>
<p>Users create one or more filter layers, adjust brightness, contrast, saturation, blur, opacity, grayscale, sepia, invert colors, hue rotation, or apply preset effects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/0e032bb3-eaac-415c-83a8-a74992754875.png" alt=" Filter Studio configuration panel showing multiple adjustable filter layers." style="display:block;margin:0 auto" width="432" height="772" loading="lazy">

<h3 id="heading-step-4-apply-the-filters">Step 4: Apply the Filters</h3>
<p>Click <strong>Apply Filters to PDF</strong> to process the selected pages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/4bc13bba-eff3-482f-9abe-6274a883a9ac.png" alt="Applying multiple filters while generating the processed PDF." style="display:block;margin:0 auto" width="297" height="95" loading="lazy">

<h3 id="heading-step-5-review-the-result">Step 5: Review the Result</h3>
<p>The completed PDF appears in the preview window for final verification.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/0a171920-de13-4567-b1fc-e6ae05ea7198.png" alt="Preview of the processed PDF before downloading." style="display:block;margin:0 auto" width="693" height="527" loading="lazy">

<h3 id="heading-step-6-rename-and-download">Step 6: Rename and Download</h3>
<p>Finally, users rename the output file if necessary, review the page count and file size, and download the edited PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e6d268c2-e550-41c1-ac2b-237ac0783aa5.png" alt="Download section with rename option, page count, file size, and Download button." style="display:block;margin:0 auto" width="682" height="517" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Applying multiple filters to high-resolution PDF pages can increase processing time. Instead of processing the entire document every time, process only the selected pages.</p>
<pre><code class="language-javascript">for (const page of selectedPages) {
    await processPage(page);
}
</code></pre>
<p>Build the Canvas filter string only when filter values change instead of rebuilding it for every render.</p>
<pre><code class="language-javascript">context.filter = buildFilterString(filters);
</code></pre>
<p>After downloading the finished document, release temporary object URLs to free memory.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(downloadUrl);
</code></pre>
<p>These optimizations help keep the application responsive, even when working with large multi-page PDF documents.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>One common mistake is drawing a filtered page on top of an already filtered canvas. Always render the original PDF page before applying a new filter configuration.</p>
<pre><code class="language-javascript">await renderPage(currentPage);
</code></pre>
<p>Another issue is forgetting to reset the Canvas filter after processing.</p>
<pre><code class="language-javascript">context.filter = "none";
</code></pre>
<p>Finally, stacking too many heavy filters (such as Blur, Glow, and multiple contrast adjustments) can increase processing time and produce unexpected visual results. Applying only the filters you actually need generally produces cleaner output and better performance.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF Filter Studio using JavaScript.</p>
<p>You learned how to upload PDF documents, render pages with PDF.js, create reusable filter layers, apply brightness, contrast, saturation, blur, opacity, grayscale, sepia, invert colors, and hue rotation effects using the Canvas API, generate a new PDF with PDF-lib, preview the processed document, rename the output file, and download it directly from the browser.</p>
<p>Unlike single-purpose PDF editing tools, this project allows users to combine multiple visual effects into a flexible editing workflow while keeping all processing local to the browser.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/pdf-filter-studio/">PDF Filter Studio</a>.</p>
<p>From here, you can extend the application with custom filter presets, AI-powered image enhancement, selective region filters, watermark overlays, or batch processing to create an even more powerful browser-based PDF editor.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Add Dynamic Features to a Static Site Without a Server ]]>
                </title>
                <description>
                    <![CDATA[ Static sites are having a moment, and it makes sense. A folder of HTML, CSS, and JavaScript files is fast to load, cheap to host, and very hard to break. Tools like Astro, Eleventy, and Hugo build tha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-add-dynamic-features-to-a-static-site-without-a-server/</link>
                <guid isPermaLink="false">6a79dc91e7c11cc73062288e</guid>
                
                    <category>
                        <![CDATA[ Static Website ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 10 Aug 2026 14:13:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9796f3aa-8b5c-4bcf-804b-57725cc2c08f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Static sites are having a moment, and it makes sense. A folder of HTML, CSS, and JavaScript files is fast to load, cheap to host, and very hard to break.</p>
<p>Tools like <a href="https://astro.build/">Astro</a>, <a href="https://www.11ty.dev/">Eleventy</a>, and <a href="https://gohugo.io/">Hugo</a> build that folder for you from Markdown files and templates. Hosts like Netlify, Vercel, and Cloudflare Pages then serve the result from a CDN, often for free.</p>
<p>Then your site needs to actually do something. A reader wants to leave a comment, or someone wants to email you. Maybe you want to show live prices, hide a page behind a login, or collect email addresses before a launch.</p>
<p>Most developers reach for a backend at this point. They spin up an Express app, add a database, and pick a hosting plan. Now they own a server, and servers need care forever.</p>
<p>The good news is that you often don't need one. Most dynamic features on a small site work fine without a server of your own, so here's how to think about the problem.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-static-does-not-mean-frozen">Static Does Not Mean Frozen</a></p>
</li>
<li><p><a href="#heading-start-with-what-the-browser-already-does">Start With What the Browser Already Does</a></p>
</li>
<li><p><a href="#heading-handling-form-submissions">Handling Form Submissions</a></p>
</li>
<li><p><a href="#heading-serverless-functions-for-the-rest">Serverless Functions for the Rest</a></p>
</li>
<li><p><a href="#heading-databases-and-logins-you-can-rent">Databases and Logins You Can Rent</a></p>
</li>
<li><p><a href="#heading-comments-search-and-payments">Comments, Search, and Payments</a></p>
</li>
<li><p><a href="#heading-know-when-you-have-outgrown-it">Know When You Have Outgrown It</a></p>
</li>
</ul>
<h2 id="heading-static-does-not-mean-frozen">Static Does Not Mean Frozen</h2>
<p>The word "static" describes how your HTML reaches the browser, not what happens after it lands. A static site can still run all the JavaScript you want. It can fetch data, rewrite the page, listen for clicks, and talk to any public API.</p>
<p>There's only one thing it can't do, which is run your code before the page is sent. That means it can't keep a secret, query a private database, or make a decision based on who is asking.</p>
<p>That single limit is the whole design problem. Once you see it clearly, the rest is just sorting. Some work belongs in the browser, some belongs in a service you rent, and a small slice needs to run somewhere private.</p>
<h2 id="heading-start-with-what-the-browser-already-does">Start With What the Browser Already Does</h2>
<p>Check the platform before you add anything to your stack, because browsers ship with more power than most of us use.</p>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API"><code>fetch</code> API</a> pulls JSON from any public endpoint. The <code>&lt;dialog&gt;</code> element gives you an accessible modal with no library, and <code>&lt;details&gt;</code> gives you an accordion. CSS now handles sticky headers, scroll-driven animation, and container queries, all of which used to need JavaScript.</p>
<p>A lot of features that seem to need a backend are really just a fetch call and a template string. A weather widget, a GitHub activity feed, a currency converter, a live score, or a Mastodon timeline: every one of those runs happily from the browser, because the data is public and the endpoint needs no key. Write the fetch, handle the loading and error states properly, and move on.</p>
<h2 id="heading-handling-form-submissions">Handling Form Submissions</h2>
<p>Forms are where most static sites hit their first real wall, because a submission has to go somewhere your visitors can't see. You can't put a database password in client-side code, and you can't send email straight from a browser.</p>
<p>Luckily this problem has been solved many times over, and most static hosts will handle forms for you. <a href="https://docs.netlify.com/manage/forms/setup/">Netlify Forms</a> needs one extra attribute on your form tag. After that, Netlify catches each submission at the edge, stores it in a dashboard, and either emails you or pings a webhook.</p>
<p>Sometimes you need more than that. Spam filters, file uploads, conditional logic, multi-step flows, payments inside the form, or rows that land in a spreadsheet with no glue code all point toward a dedicated tool. This roundup of the <a href="https://forms.app/en/blog/best-form-builders">best form builders</a> is a good way to compare your options, since the real differences come down to logic, integrations, and how much control you keep over the markup.</p>
<p>Either way, the choice is about ownership. Write your own form endpoint and you own the validation, the rate limits, the spam defense, the storage, the alerts, and the deletion requests that arrive under GDPR. That's a lot of work for a contact form. Build it yourself when the form is part of your product, and rent it when the form is just a way for people to reach you.</p>
<h2 id="heading-serverless-functions-for-the-rest">Serverless Functions for the Rest</h2>
<p>Sooner or later you will need to run a few lines of code in private, whether to sign a request, hide an API key, or reshape a response before the browser sees it. That is exactly what serverless functions are for, and they're the smallest possible step away from a purely static site.</p>
<p>You drop a file into a folder, and your host turns it into a URL on the next deploy. That's the entire setup, and it works the same way in <a href="https://docs.netlify.com/build/functions/overview/">Netlify Functions</a>, <a href="https://vercel.com/docs/functions">Vercel Functions</a>, and <a href="https://developers.cloudflare.com/pages/functions/">Cloudflare Pages Functions</a>. The code stays short:</p>
<pre><code class="language-js">export default async function handler(request) {
  const res = await fetch("https://api.example.com/data", {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
  });
  const data = await res.json();
  return Response.json({ total: data.items.length });
}
</code></pre>
<p>Your key lives in an environment variable on the host, so the browser calls <code>/api/handler</code> and never sees it. There's no server to keep alive, no operating system to patch, and no bill on a quiet day.</p>
<p>This is the right home for anything that needs a credential. Use it to send email through Resend or Postmark, call an AI model, check a webhook signature, or proxy a paid API whose key would otherwise leak within a day.</p>
<h2 id="heading-databases-and-logins-you-can-rent">Databases and Logins You Can Rent</h2>
<p>State is the last piece people assume needs a server, but it does not. <a href="https://supabase.com/docs">Supabase</a> puts Postgres behind a REST API with row-level security. Those rules live in the database itself, so you can query it from the browser and still be safe. Firebase does much the same with a different data model, while Cloudflare D1 and Turso give you SQLite at the edge.</p>
<p>Logins follow the same pattern. Clerk, Auth0, and Supabase Auth all handle sessions, password resets, social logins, and two-factor codes behind a client SDK. Authentication is genuinely hard to get right, so leaning on an audited service here isn't laziness. It's sensible risk management.</p>
<h2 id="heading-comments-search-and-payments">Comments, Search, and Payments</h2>
<p>The last few common features have drop-in answers too. <a href="https://giscus.app/">Giscus</a> turns GitHub Discussions into a comment box with a script tag and no database at all. <a href="https://pagefind.app/">Pagefind</a> builds a search index while your site builds and ships it as static files, so full-text search costs you nothing at runtime.</p>
<p>Stripe covers money. Payment Links and Checkout let you sell something with a plain link, and Stripe handles the card, the tax, and the receipt. Your own site never touches card data.</p>
<h2 id="heading-know-when-you-have-outgrown-it">Know When You Have Outgrown It</h2>
<p>This model has a real ceiling, and you should be aware of it rather than fight it. Watch for a few signs: your logic is now spread across six services, your monthly SaaS bill beats the cost of a small server, cold starts are hurting real users, or you need long jobs, a queue, or a socket that stays open for hours. When that day comes, a regular application server is the simpler tool, so move.</p>
<p>Until then, this setup buys you things that matter. Your site stays fast by default, your attack surface stays small, and your time goes into the product instead of the plumbing. Start with the browser, add a function when you need a secret, and rent the hard parts.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF Color Inverter Tool Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Reading PDF documents for long periods can become tiring, especially when the document contains bright backgrounds or when you're working in a low-light environment. In other situations, designers, de ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-color-inverter-tool-javascript/</link>
                <guid isPermaLink="false">6a750800a316383dcb915436</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdfjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Thu, 06 Aug 2026 22:17:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e4b5fb66-d353-4c78-adb3-ddc1f3d5594d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Reading PDF documents for long periods can become tiring, especially when the document contains bright backgrounds or when you're working in a low-light environment.</p>
<p>In other situations, designers, developers, and print professionals may want to inspect how a document looks with inverted colors or prepare alternative versions for accessibility and review.</p>
<p>A PDF Color Inverter Tool makes this possible by transforming the colors of PDF pages while keeping the document structure intact. Instead of editing every image or graphic manually, users can upload a PDF, invert its colors, preview the results, and download a newly generated document in just a few clicks.</p>
<p>In this tutorial, you'll build a browser-based PDF Color Inverter Tool using JavaScript. Users will be able to upload a PDF, preview its pages, choose an inversion mode, specify the page range to process, adjust the output quality, enable live preview, generate the inverted PDF, review the result, rename the output file, and download it, all without uploading the document to a server.</p>
<p>We'll use PDF.js to render PDF pages inside the browser, the HTML Canvas API to manipulate pixel colors, and PDF-lib to generate the final PDF.</p>
<p>By the end of this tutorial, you'll have a fully functional client-side PDF color inversion tool similar to the one available on my site All In One Tools.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-color-inverter-tool-does-and-how-it-works">What This PDF Color Inverter Tool Does and How It Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-building-the-color-inversion-settings">Building the Color Inversion Settings</a></p>
</li>
<li><p><a href="#heading-inverting-pdf-colors">Inverting PDF Colors</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-color-inverter-tool-works">Demo: How the PDF Color Inverter Tool Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-color-inverter-tool-does-and-how-it-works">What This PDF Color Inverter Tool Does and How It Works</h2>
<p>A PDF Color Inverter Tool changes the appearance of a PDF by reversing the colors of its pages. Light colors become dark, dark colors become light, and every pixel is recalculated to create an inverted version of the original document. This can improve readability in certain environments, help preview designs in dark mode, or simply provide an alternative way to view a document.</p>
<p>In this project, users can upload a PDF, browse through every page, choose how the colors should be inverted, define the page range to process, select the output quality, enable a live preview, generate the inverted document, rename the output file, and download the finished PDF. Since all processing happens locally inside the browser, the original document never leaves the user's device.</p>
<p>Behind the scenes, <strong>PDF.js</strong> renders each PDF page onto an HTML canvas. Once a page is rendered, JavaScript accesses the pixel data using the Canvas API. Every pixel's red, green, and blue values are recalculated to create the inverted version of the page.</p>
<p>After processing all selected pages, <strong>PDF-lib</strong> assembles the modified pages into a new PDF that users can preview and download.</p>
<p>A single pixel is represented by four values:</p>
<pre><code class="language-javascript">const pixel = {

    red: 120,

    green: 85,

    blue: 200,

    alpha: 255

};
</code></pre>
<p>During color inversion, each color channel is transformed by subtracting its value from <strong>255</strong>.</p>
<pre><code class="language-javascript">red = 255 - red;

green = 255 - green;

blue = 255 - blue;
</code></pre>
<p>Repeating this calculation for every pixel on every selected page produces the final inverted PDF while preserving the document's layout, page order, and dimensions.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing any image-processing code, let's create a simple project structure for our PDF Color Inverter Tool.</p>
<p>We'll build the application using plain HTML, CSS, and JavaScript together with two libraries:</p>
<ul>
<li><p>PDF.js for rendering PDF pages inside the browser.</p>
</li>
<li><p>PDF-lib for generating the final inverted PDF.</p>
</li>
</ul>
<p>Our project structure looks like this:</p>
<pre><code class="language-text">pdf-color-inverter/

│── index.html
│── style.css
│── script.js
│── pdf.worker.min.js
│── assets/
</code></pre>
<p>Keeping everything separated makes the application easier to understand and maintain.</p>
<p>Include the required libraries before loading your own JavaScript.</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;

&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;

&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc =
    "pdf.worker.min.js";
</code></pre>
<p>Running PDF.js inside a worker keeps the browser responsive while rendering large PDF files.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application contains four primary sections:</p>
<ul>
<li><p>Upload area</p>
</li>
<li><p>Settings panel</p>
</li>
<li><p>PDF preview</p>
</li>
<li><p>Result section</p>
</li>
</ul>
<p>Create the basic structure.</p>
<pre><code class="language-html">&lt;section id="uploadSection"&gt;&lt;/section&gt;

&lt;section id="settingsSection" hidden&gt;&lt;/section&gt;

&lt;section id="previewSection" hidden&gt;&lt;/section&gt;

&lt;section id="resultSection" hidden&gt;&lt;/section&gt;
</code></pre>
<p>Only the upload area is visible when the page first loads.</p>
<p>After a PDF has been selected, the remaining sections become available.</p>
<h2 id="heading-selecting-the-main-elements">Selecting the Main Elements</h2>
<p>Store references to the elements used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection =
document.getElementById(
    "uploadSection"
);

const settingsSection =
document.getElementById(
    "settingsSection"
);

const previewSection =
document.getElementById(
    "previewSection"
);

const resultSection =
document.getElementById(
    "resultSection"
);

const pdfCanvas =
document.getElementById(
    "pdfCanvas"
);
</code></pre>
<p>These references make it easy to switch between different stages of the workflow.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload area accepts drag-and-drop as well as manual file selection.</p>
<p>When a file is selected, verify that it's actually a PDF.</p>
<pre><code class="language-javascript">async function handleUpload(
    file
) {

    if (
        !file ||
        file.type !==
        "application/pdf"
    ) {

        alert(
            "Please select a PDF file."
        );

        return;

    }

    await loadPdf(file);

}
</code></pre>
<p>If the file passes validation, the browser loads it into memory.</p>
<h3 id="heading-supporting-password-protected-pdfs">Supporting Password-Protected PDFs</h3>
<p>Some documents are protected with a password.</p>
<p>The upload screen provides an optional password field before processing begins.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a5c65e5d-290e-4a8d-b60a-70bd8e88e35f.png" alt="Upload area showing an optional password field for protected PDF documents." style="display:block;margin:0 auto" width="1331" height="670" loading="lazy">

<p>Retrieve the entered password.</p>
<pre><code class="language-javascript">const password =
document
.getElementById(
    "pdfPassword"
)
.value
.trim();
</code></pre>
<p>Pass the password to PDF.js while loading the document.</p>
<pre><code class="language-javascript">const loadingTask =
pdfjsLib.getDocument({

    data: pdfBytes,

    password

});

pdfDocument =
await loadingTask.promise;
</code></pre>
<p>If the document isn't password protected, the password field can simply remain empty.</p>
<h3 id="heading-loading-the-pdf">Loading the PDF</h3>
<p>Convert the uploaded file into an ArrayBuffer.</p>
<pre><code class="language-javascript">async function loadPdf(
    file
) {

    originalPdfBytes =
    await file.arrayBuffer();

    pdfDocument =
    await pdfjsLib
        .getDocument({

            data:
            originalPdfBytes

        })
        .promise;

    currentPage = 1;

    await renderPage(
        currentPage
    );

}
</code></pre>
<p>The original bytes are preserved because they'll later be used to generate the inverted PDF.</p>
<h3 id="heading-rendering-pdf-pages">Rendering PDF Pages</h3>
<p>PDF.js renders one page at a time.</p>
<p>Retrieve the requested page.</p>
<pre><code class="language-javascript">async function renderPage(
    pageNumber
) {

    const page =
    await pdfDocument.getPage(
        pageNumber
    );

    const viewport =
    page.getViewport({

        scale: 1.5

    });
</code></pre>
<p>Resize the canvas.</p>
<pre><code class="language-javascript">pdfCanvas.width =
viewport.width;

pdfCanvas.height =
viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({

    canvasContext:

    pdfCanvas.getContext(
        "2d"
    ),

    viewport

}).promise;
</code></pre>
<p>After rendering completes, the selected page becomes visible inside the preview area.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6fb70ed3-70ae-489b-99ba-7f822cff3579.png" alt="Page-by-page PDF preview displayed after the document has been uploaded." style="display:block;margin:0 auto" width="833" height="697" loading="lazy">

<h3 id="heading-navigating-between-pages">Navigating Between Pages</h3>
<p>Most documents contain multiple pages, so the preview includes Previous and Next buttons.</p>
<p>Create the page state.</p>
<pre><code class="language-javascript">let currentPage = 1;

let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton
.addEventListener(

"click",

async () =&gt; {

    if (
        currentPage &gt; 1
    ) {

        currentPage--;

        await renderPage(
            currentPage
        );

    }

});
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton
.addEventListener(

"click",

async () =&gt; {

    if (

        currentPage &lt;

        pdfDocument.numPages

    ) {

        currentPage++;

        await renderPage(
            currentPage
        );

    }

});
</code></pre>
<p>Update the page indicator.</p>
<pre><code class="language-javascript">pageNumber.textContent =

`Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>This allows users to browse through the document before choosing which pages should have their colors inverted.</p>
<h2 id="heading-building-the-color-inversion-settings">Building the Color Inversion Settings</h2>
<p>Before processing the PDF, users should be able to control how the colors are inverted. The settings panel lets users choose the inversion mode, specify which pages should be processed, select the output quality, enable a live preview, or reset everything and start over.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d98128ce-5c68-4eda-a10c-c0297aed3b41.png" alt="PDF Color Inverter settings panel showing inversion mode, page range, output quality, live preview, and reset options." style="display:block;margin:0 auto" width="902" height="361" loading="lazy">

<h3 id="heading-choosing-the-inversion-mode">Choosing the Inversion Mode</h3>
<p>The first option allows users to choose how the document colors should be inverted.</p>
<p>Create the dropdown.</p>
<pre><code class="language-html">&lt;select id="invertMode"&gt;

    &lt;option value="full"&gt;

        Full Invert

    &lt;/option&gt;

&lt;/select&gt;
</code></pre>
<p>Read the selected mode.</p>
<pre><code class="language-javascript">const inversionMode =

document
.getElementById(
    "invertMode"
)
.value;
</code></pre>
<p>The selected value determines which color transformation is applied during processing.</p>
<h3 id="heading-selecting-the-page-range">Selecting the Page Range</h3>
<p>Sometimes users only need to invert a few pages instead of the entire document.</p>
<p>Create two input fields.</p>
<pre><code class="language-html">&lt;input

type="number"

id="startPage"

min="1"&gt;

&lt;input

type="number"

id="endPage"

min="1"&gt;
</code></pre>
<p>Retrieve the selected pages.</p>
<pre><code class="language-javascript">const startPage =

Number(
startPageInput.value
);

const endPage =

Number(
endPageInput.value
);
</code></pre>
<p>Only the pages inside this range will be processed when generating the final PDF.</p>
<h3 id="heading-choosing-the-output-quality">Choosing the Output Quality</h3>
<p>The tool provides multiple quality levels so users can balance image quality and file size.</p>
<p>Create the quality selector.</p>
<pre><code class="language-html">&lt;select id="outputQuality"&gt;

    &lt;option value="low"&gt;

        Low (Smaller Size)

    &lt;/option&gt;

    &lt;option value="medium"&gt;

        Medium

    &lt;/option&gt;

    &lt;option value="high"&gt;

        High (Best Quality)

    &lt;/option&gt;

&lt;/select&gt;
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/006be0e0-82a9-4b9e-8376-c6d9bb604a12.png" alt="Output Quality dropdown showing Low, Medium, and High options." style="display:block;margin:0 auto" width="431" height="187" loading="lazy">

<p>Read the selected quality.</p>
<pre><code class="language-javascript">const quality =

document
.getElementById(
    "outputQuality"
)
.value;
</code></pre>
<p>The selected value will later determine the image quality used while generating the new PDF.</p>
<h3 id="heading-enabling-live-preview">Enabling Live Preview</h3>
<p>The Live Preview switch lets users instantly see the inverted colors without generating a new PDF.</p>
<p>Create the toggle.</p>
<pre><code class="language-html">&lt;input

type="checkbox"

id="livePreview"&gt;
</code></pre>
<p>Read its state.</p>
<pre><code class="language-javascript">const livePreview =

document
.getElementById(
    "livePreview"
)
.checked;
</code></pre>
<p>Whenever the setting changes, refresh the preview.</p>
<pre><code class="language-javascript">livePreviewToggle
.addEventListener(

"change",

updatePreview

);
</code></pre>
<p>When enabled, the preview canvas updates automatically as users change the inversion settings.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ec38d1d9-02a9-4ecb-b29d-8451232def9e.png" alt="Live Preview enabled showing inverted PDF page thumbnails." style="display:block;margin:0 auto" width="908" height="552" loading="lazy">

<h3 id="heading-resetting-the-settings">Resetting the Settings</h3>
<p>The Reset button clears the current configuration so users can begin again without reloading the page.</p>
<p>Create the button.</p>
<pre><code class="language-html">&lt;button id="resetButton"&gt;

Reset / Clear

&lt;/button&gt;
</code></pre>
<p>Restore the default values.</p>
<pre><code class="language-javascript">function resetSettings() {

    invertMode.value =
    "full";

    outputQuality.value =
    "high";

    livePreview.checked =
    false;

}
</code></pre>
<p>Attach the event listener.</p>
<pre><code class="language-javascript">resetButton
.addEventListener(

"click",

resetSettings

);
</code></pre>
<p>This returns the settings panel to its initial state.</p>
<h3 id="heading-starting-the-color-inversion">Starting the Color Inversion</h3>
<p>Once the settings have been reviewed, users can begin processing the document.</p>
<p>Create the action button.</p>
<pre><code class="language-html">&lt;button id="invertPdf"&gt;

Invert PDF Colors

&lt;/button&gt;
</code></pre>
<p>Start the inversion process.</p>
<pre><code class="language-javascript">invertButton
.addEventListener(

"click",

async () =&gt; {

    await invertPdf();

});
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d779889d-5c5e-462a-94d6-f33234b15426.png" alt=" Invert PDF Colors button below the page previews." style="display:block;margin:0 auto" width="290" height="69" loading="lazy">

<h2 id="heading-inverting-pdf-colors">Inverting PDF Colors</h2>
<p>Now we'll build the core feature of the application: reversing the colors of each PDF page.</p>
<p>The workflow is straightforward. First, PDF.js renders a page onto an HTML canvas. Next, JavaScript reads every pixel from the canvas, inverts its red, green, and blue values, and writes the updated pixels back. Finally, the processed page is added to a new PDF using PDF-lib.</p>
<h3 id="heading-reading-canvas-pixel-data">Reading Canvas Pixel Data</h3>
<p>Once a page has been rendered, retrieve its pixel information.</p>
<pre><code class="language-javascript">const imageData =

context.getImageData(

    0,

    0,

    canvas.width,

    canvas.height

);
</code></pre>
<p>Each pixel consists of four values:</p>
<ul>
<li><p>Red</p>
</li>
<li><p>Green</p>
</li>
<li><p>Blue</p>
</li>
<li><p>Alpha (Transparency)</p>
</li>
</ul>
<p>The pixel data is stored inside an array.</p>
<pre><code class="language-javascript">const pixels =
imageData.data;
</code></pre>
<p>We'll modify this array directly.</p>
<h3 id="heading-inverting-every-pixel">Inverting Every Pixel</h3>
<p>To invert a color, subtract each RGB value from 255.</p>
<p>Loop through every pixel.</p>
<pre><code class="language-javascript">for (

    let i = 0;

    i &lt; pixels.length;

    i += 4

) {

    pixels[i] =
    255 - pixels[i];

    pixels[i + 1] =
    255 - pixels[i + 1];

    pixels[i + 2] =
    255 - pixels[i + 2];

}
</code></pre>
<p>The alpha channel remains unchanged so transparent elements continue to render correctly.</p>
<p>After updating every pixel, write the modified image back onto the canvas.</p>
<pre><code class="language-javascript">context.putImageData(

    imageData,

    0,

    0

);
</code></pre>
<p>The page preview now displays the inverted colors.</p>
<h3 id="heading-updating-the-live-preview">Updating the Live Preview</h3>
<p>If <strong>Live Preview</strong> is enabled, users should immediately see the changes without generating a new PDF.</p>
<p>Check whether the feature is active.</p>
<pre><code class="language-javascript">if (

    livePreview.checked

) {

    await invertCurrentPage();

}
</code></pre>
<p>Whenever users change the inversion mode, page range, or quality settings, refresh the preview.</p>
<pre><code class="language-javascript">async function updatePreview() {

    await renderPage(

        currentPage

    );

    await invertCurrentPage();

}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/dbb6058b-207d-4f42-a958-cc31b386a03f.png" alt="Live Preview enabled showing PDF pages with inverted colors." style="display:block;margin:0 auto" width="908" height="552" loading="lazy">

<h3 id="heading-processing-the-selected-pages">Processing the Selected Pages</h3>
<p>Instead of processing the entire document every time, only invert the pages selected by the user.</p>
<p>Loop through the chosen page range.</p>
<pre><code class="language-javascript">for (

    let page = startPage;

    page &lt;= endPage;

    page++

) {

    await processPage(
        page
    );

}
</code></pre>
<p>Each processed page is temporarily stored before generating the final PDF.</p>
<h3 id="heading-generating-the-final-pdf">Generating the Final PDF</h3>
<p>Create a new PDF document.</p>
<pre><code class="language-javascript">const outputPdf =

await PDFLib
.PDFDocument
.create();
</code></pre>
<p>Convert the processed canvas into an image.</p>
<pre><code class="language-javascript">const imageBytes =

await canvasToBytes(
    pdfCanvas
);
</code></pre>
<p>Embed the image.</p>
<pre><code class="language-javascript">const image =

await outputPdf
.embedPng(
    imageBytes
);
</code></pre>
<p>Create a page.</p>
<pre><code class="language-javascript">const page =

outputPdf.addPage([

    image.width,

    image.height

]);
</code></pre>
<p>Draw the processed image.</p>
<pre><code class="language-javascript">page.drawImage(

    image,

    {

        x: 0,

        y: 0,

        width: image.width,

        height: image.height

    }

);
</code></pre>
<p>Repeat these steps for every selected page.</p>
<h3 id="heading-saving-the-finished-pdf">Saving the Finished PDF</h3>
<p>Once all pages have been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes =

await outputPdf.save();
</code></pre>
<p>Convert the generated bytes into a downloadable file.</p>
<pre><code class="language-javascript">generatedPdfBlob =

new Blob(

    [pdfBytes],

    {

        type:
        "application/pdf"

    }

);
</code></pre>
<p>The generated PDF is now ready for preview and download.</p>
<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Before downloading the processed document, it's useful to let users review the final output. This allows them to verify that the selected pages have been inverted correctly and that the document appears as expected.</p>
<p>Load the generated PDF into PDF.js.</p>
<pre><code class="language-javascript">async function showPreview() {

    const bytes =
        await generatedPdfBlob
            .arrayBuffer();

    finalPdf =
        await pdfjsLib
            .getDocument({

                data: bytes

            })
            .promise;

    renderFinalPage(1);

}
</code></pre>
<p>Render the selected page.</p>
<pre><code class="language-javascript">async function renderFinalPage(
    pageNumber
) {

    const page =
        await finalPdf.getPage(
            pageNumber
        );

    const viewport =
        page.getViewport({

            scale: 1.5

        });

    finalCanvas.width =
        viewport.width;

    finalCanvas.height =
        viewport.height;

    await page.render({

        canvasContext:
        finalCanvas
            .getContext("2d"),

        viewport

    }).promise;

}
</code></pre>
<p>Users can browse through the processed document before downloading it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/234e0566-0622-44bc-9509-39df2b9fb584.png" alt="Final PDF preview showing inverted colors before downloading." style="display:block;margin:0 auto" width="835" height="397" loading="lazy">

<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before saving the PDF, users can provide a custom filename.</p>
<p>Create the filename field.</p>
<pre><code class="language-html">&lt;input

type="text"

id="outputFilename"

value="inverted-document.pdf"&gt;
</code></pre>
<p>Retrieve the filename.</p>
<pre><code class="language-javascript">function getFilename() {

    let filename =

    outputFilename
        .value
        .trim();

    if (!filename) {

        filename =
        "inverted-document.pdf";

    }

    if (
        !filename
        .endsWith(".pdf")
    ) {

        filename += ".pdf";

    }

    return filename;

}
</code></pre>
<p>Display additional file information.</p>
<pre><code class="language-javascript">pageCount.textContent =

`${finalPdf.numPages} Pages`;

fileSize.textContent =

formatFileSize(

    generatedPdfBlob.size

);
</code></pre>
<p>Download the generated PDF.</p>
<pre><code class="language-javascript">downloadButton
.addEventListener(

"click",

() =&gt; {

    const url =

    URL.createObjectURL(
        generatedPdfBlob
    );

    const link =
        document.createElement(
            "a"
        );

    link.href =
        url;

    link.download =
        getFilename();

    link.click();

    URL.revokeObjectURL(
        url
    );

});
</code></pre>
<p>Everything happens locally inside the browser, so users can download the processed PDF immediately after reviewing it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1d72201e-4c1e-4efe-8c64-dae32bd9c731.png" alt="Download section showing the renamed PDF filename, page count, file size, and Download button." style="display:block;margin:0 auto" width="289" height="237" loading="lazy">

<h2 id="heading-demo-how-the-pdf-color-inverter-tool-works">Demo: How the PDF Color Inverter Tool Works</h2>
<p>Let's walk through the complete workflow.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>. If the document is password protected, the password can be entered before loading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/89ffe01f-6aa2-4174-88f4-73151e5c8baf.png" alt="Upload screen with drag-and-drop support, Select PDF button, and password field." style="display:block;margin:0 auto" width="1331" height="670" loading="lazy">

<h3 id="heading-step-2-preview-the-document">Step 2: Preview the Document</h3>
<p>The uploaded PDF is rendered page by page, allowing users to browse the document before making any changes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f263b6d4-141e-443f-8939-57f10c6bff46.png" alt="Page-by-page PDF preview before applying color inversion." style="display:block;margin:0 auto" width="833" height="697" loading="lazy">

<h3 id="heading-step-3-configure-the-settings">Step 3: Configure the Settings</h3>
<p>Users choose the page range, output quality, and whether Live Preview should be enabled.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/7f90aa16-988d-49e1-a292-ca4938007765.png" alt="Settings panel showing inversion options, page range, output quality, and live preview." style="display:block;margin:0 auto" width="902" height="361" loading="lazy">

<h3 id="heading-step-4-preview-the-inverted-colors">Step 4: Preview the Inverted Colors</h3>
<p>When Live Preview is enabled, the current page updates immediately so users can review the inverted appearance before processing the complete document.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e1790ba0-2c89-4632-b84e-53bed26481fa.png" alt="Live Preview displaying the current page with inverted colors." style="display:block;margin:0 auto" width="908" height="552" loading="lazy">

<h3 id="heading-step-5-generate-the-pdf">Step 5: Generate the PDF</h3>
<p>Clicking <strong>Invert PDF Colors</strong> processes the selected pages and creates a new PDF containing the inverted pages.</p>
<h3 id="heading-step-6-review-and-download">Step 6: Review and Download</h3>
<p>The generated PDF appears in the final preview. Users can rename the output file, review the page count and file size, and download the completed document.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/20685eba-4e6a-4601-992a-f23e384740f1.png" alt="Final PDF preview with rename field, page count, file size, and Download button." style="display:block;margin:0 auto" width="835" height="397" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Large PDF files can require additional processing time. Rendering only the current page, updating the preview instead of reloading the entire document, and processing only the selected page range can significantly improve performance.</p>
<pre><code class="language-javascript">for (

    let page = startPage;

    page &lt;= endPage;

    page++

) {

    await processPage(
        page
    );

}
</code></pre>
<p>After the download completes, release temporary resources.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(
    downloadUrl
);
</code></pre>
<p>These small optimizations help keep the application responsive, even when processing large PDF files.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>A common mistake is modifying the original canvas repeatedly without first rendering a fresh copy of the PDF page. This can cause colors to be inverted multiple times.</p>
<p>Always render the original page before applying another inversion.</p>
<pre><code class="language-javascript">await renderPage(
    currentPage
);
</code></pre>
<p>Another issue is processing page numbers outside the valid range.</p>
<pre><code class="language-javascript">if (

    pageNumber &lt; 1 ||

    pageNumber &gt;

    pdfDocument.numPages

) {

    return;

}
</code></pre>
<p>Finally, remember that higher output quality generally produces larger PDF files. Users should choose a quality level that balances image clarity and file size for their specific needs.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF Color Inverter Tool using JavaScript.</p>
<p>You learned how to upload PDF documents, support password-protected files, render pages with PDF.js, manipulate pixel colors using the HTML Canvas API, generate a new PDF with PDF-lib, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Because all processing takes place locally, users can invert PDF colors without uploading sensitive documents to an external server.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/pdf-color-inverter/">PDF Color Inverter Tool.</a></p>
<p>This project can be extended further by adding custom color filters, selective page previews, brightness and contrast adjustments, grayscale conversion, sepia effects, batch processing, or additional document enhancement tools.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What Happens During a Production Deployment? A Behind-the-Scenes Guide ]]>
                </title>
                <description>
                    <![CDATA[ You push your code. A few minutes later, it is live for real users. Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every produc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-happens-during-a-production-deployment/</link>
                <guid isPermaLink="false">6a70dada6358084ff948ee0f</guid>
                
                    <category>
                        <![CDATA[ deployment ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:15:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/43567dce-ecbe-412e-ab40-2ef6e07dde0e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You push your code. A few minutes later, it is live for real users.</p>
<p>Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every production engineer depends on that chain, and many teams still build and operate it themselves.</p>
<p>Deployment infrastructure has quietly become operational overhead. It started as a technical necessity, something every team had to assemble because nothing else existed.</p>
<p>Today it is a second system your engineers maintain alongside the product, consuming on-call rotations, sprint capacity, and 2 a.m. attention that could go somewhere better.</p>
<p>In this article, we'll walk through each stage of a real production deployment: the build, the artefact it produces, database migrations, health checks, rolling updates, and rollbacks. Along the way, we'll look at why <a href="https://www.freecodecamp.org/news/my-team-s-experience-moving-from-aws-to-a-paas/">platform-as-a-service (PaaS)</a> tools handle most of these steps for you, and what it costs a team to keep handling them itself.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-build-turning-code-into-something-that-can-run">The Build: Turning Code into Something That Can Run</a></p>
</li>
<li><p><a href="#heading-the-artefact-one-version-frozen-in-time">The Artefact: One Version, Frozen in Time</a></p>
</li>
<li><p><a href="#heading-database-migrations-the-riskiest-step">Database Migrations: The Riskiest Step</a></p>
</li>
<li><p><a href="#heading-health-checks-proving-the-new-version-is-alive">Health Checks: Proving the New Version Is Alive</a></p>
</li>
<li><p><a href="#heading-rolling-updates-replacing-the-planes-engine-mid-flight">Rolling Updates: Replacing the Plane's Engine Mid-Flight</a></p>
</li>
<li><p><a href="#heading-rollbacks-the-escape-hatch">Rollbacks: The Escape Hatch</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</a></p>
</li>
<li><p><a href="#heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</a></p>
</li>
</ul>
<h2 id="heading-the-build-turning-code-into-something-that-can-run"><strong>The Build: Turning Code into Something That Can Run</strong></h2>
<p>A deployment does not ship your source code as-is. It ships the result of a build. The build stage takes your code and turns it into something a server can run.</p>
<p>What this looks like depends on your stack. A Java or Go project gets compiled into a binary. A JavaScript front end gets bundled and minified. A Python app gets its dependencies resolved and pinned. In most modern setups, all of this gets packed into a <a href="https://www.freecodecamp.org/news/an-introduction-to-docker-and-containers-for-beginners/">container image</a>, which is a frozen snapshot of your app plus everything it needs to run.</p>
<p>The build stage also runs your tests. Unit tests, linting, and security scans all happen here. If any of them fail, the deployment stops before it can touch production. This is the cheapest place to catch a bug. A failed build costs you a few minutes. A failed deployment can cost you customers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/9a8b5d93-802c-4898-bd07-91a90041f93a.svg" alt="stages of code deployment" style="display:block;margin:0 auto" width="680" height="520" loading="lazy">

<p>Teams that run their own pipelines spend real effort here. They maintain build servers, cache dependencies, and debug flaky test runners.</p>
<p>None of that work ships a feature. It is pure upkeep, and it never ends. A PaaS bakes this whole stage into the platform. You push code, and the platform detects your language, builds it the same way every time, and fails fast when something is wrong. The build still happens. Your engineers just stop paying for it in hours.</p>
<h2 id="heading-the-artefact-one-version-frozen-in-time"><strong>The Artefact: One Version, Frozen in Time</strong></h2>
<p>The output of a build is called an artefact. It might be a container image, a compiled binary, or a zipped bundle. Whatever the format, the artefact has one job: to be exact. It represents one precise version of your app, frozen at one point in time.</p>
<p>This matters more than it sounds. The artefact that passed your tests must be the exact same one that reaches production. If you rebuild between testing and shipping, you risk shipping something slightly different. A dependency may have updated. A build flag may have changed. "It worked in staging" often means "we built it twice and got two different results."</p>
<p>Good pipelines build once and promote the same artefact through every stage. Artefacts get versioned and stored in a registry, so any version can be pulled and run again later. That stored history is also what makes rollbacks possible, which we will get to soon.</p>
<p>On a PaaS, artefact handling is standard practice by default. Every deploy produces a numbered release. The platform stores it, tracks it, and can restore it. You do not have to design a registry strategy, write promotion scripts, or assign an engineer to own them. The discipline is built in.</p>
<h2 id="heading-database-migrations-the-riskiest-step"><strong>Database Migrations: The Riskiest Step</strong></h2>
<p>Before new code goes live, the database often has to change with it. Maybe the new version needs a new column or a new table. These changes are called migrations, and they are the most dangerous part of most deployments.</p>
<p>Why? Code is easy to replace. Data is not. If you deploy a bad code version, you can swap it out. If a migration corrupts or drops data, there may be no clean way back. Migrations also create a tricky window of time. For a few minutes, old code and new code may run against the same database at once. Both versions have to work with the schema during that window.</p>
<p>The safe pattern is to make migrations backwards-compatible. Add the new column first, deploy code that can handle both shapes, then clean up the old column in a later release. It takes more steps, but each step is safe on its own.</p>
<p>A PaaS cannot write your migrations for you. No tool can know what your data means. But a good platform gives migrations a defined place in the release process, runs them in order, and logs exactly what ran and when. That structure prevents the classic failure where someone runs a migration by hand and forgets to tell the team.</p>
<h2 id="heading-health-checks-proving-the-new-version-is-alive"><strong>Health Checks: Proving the New Version Is Alive</strong></h2>
<p>Once the new version starts, the platform does not just trust it. It checks. A health check is a small endpoint in your app, often just a route that returns "OK." The platform calls it over and over. If the app answers, it is considered healthy. If it does not, the platform assumes something is wrong.</p>
<p>There are usually two kinds of checks. A readiness check asks, "Are you ready to receive traffic?" A liveness check asks, "Are you still working, or should I restart you?" The difference matters. An app can be alive but not ready, such as when it is still warming up a cache.</p>
<p>Health checks are the gatekeepers of a deployment. No traffic reaches a new version until it proves it can handle requests. Without them, you would be routing real users to an app that might still be crashing on startup.</p>
<p>Every serious PaaS runs health checks automatically. You define the endpoint, and the platform handles the polling, the timeouts, and the decisions. Teams that build this themselves tune all of those settings by hand, and they usually learn the right values through painful trial and error. That tuition is paid in engineering time, on a problem the industry solved years ago.</p>
<h2 id="heading-rolling-updates-replacing-the-planes-engine-mid-flight"><strong>Rolling Updates: Replacing the Plane's Engine Mid-Flight</strong></h2>
<p>Here is the hard part. Your old version is serving live traffic right now. You need to replace it without dropping a single request. The most common answer is a <a href="https://kubernetes.io/docs/tutorials/kubernetes-basics/update/update-intro/">rolling update</a>.</p>
<p>It works like this. Say you have four copies of your app running. The platform starts one copy of the new version and waits for its health checks to pass. Then it shifts a slice of traffic to it and shuts down one old copy. It repeats this, one copy at a time, until only the new version remains. Users never notice, because at every moment there are enough healthy copies to serve everyone.</p>
<p>Some teams use variations of this idea. A blue-green deployment runs the full new version beside the old one, then flips all traffic at once. A canary release sends a tiny share of users to the new version first, watching for errors before going wider.</p>
<p>Doing this by hand means writing orchestration logic, managing load balancer rules, and handling every edge case where a step fails halfway. That is months of engineering effort to build and a permanent tax to maintain, all for behavior a PaaS ships as the default. On a platform, you get zero-downtime releases out of the box, not as a project your team has to staff.</p>
<h2 id="heading-rollbacks-the-escape-hatch"><strong>Rollbacks: The Escape Hatch</strong></h2>
<p>Sometimes the new version passes every check and still breaks something real. An error rate climbs. A page loads blank. Now speed matters more than anything, and the fastest fix is rarely a new patch. It is a rollback: redeploying the previous artefact that you already know works.</p>
<p>This is why frozen, versioned artefacts are so important. A rollback is only fast if the old version is stored, tested, and ready to run. Teams that rebuild from an old commit under pressure are gambling at the worst possible time.</p>
<p>On most PaaS platforms, a rollback is one command or one click. The platform keeps your release history and can restore any previous version in seconds. That single feature has saved more on-call engineers' nights than perhaps any other.</p>
<h2 id="heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</h2>
<p>The case for handing deployment to a platform is strong, but it isn't universal. There are teams for whom owning the pipeline is not overhead; it is a deliberate and justified engineering decision.</p>
<h3 id="heading-when-compliance-demands-it">When Compliance Demands It</h3>
<p>Regulated industries like finance, healthcare, government, often operate under requirements that a standard PaaS cannot satisfy out of the box. Data residency rules may dictate exactly which physical infrastructure your builds touch. Audit requirements may demand a level of provenance and access logging that a managed platform doesn't expose.</p>
<p>Security controls may need to extend into the build environment itself, not just the runtime. In these contexts, the cost of owning the pipeline is real, but it is the cost of operating in that industry.</p>
<h3 id="heading-when-deployment-is-your-product">When Deployment is Your Product</h3>
<p>If your company sells deployment infrastructure, a CI/CD platform, a release orchestration tool, an internal developer platform, then your pipeline is not overhead at all. It is the product.</p>
<p>The engineers maintaining it are doing product work, not distraction work. The same applies to platform engineering teams at large organizations whose explicit charter is to build and own the deployment layer for dozens of other internal teams. In both cases, the question of "why are we running this ourselves" has an obvious answer: because this is what we do.</p>
<h3 id="heading-when-your-infrastructure-is-genuinely-unusual">When Your Infrastructure is Genuinely Unusual</h3>
<p>Most PaaS platforms are optimized for stateless web services and standard container workloads. If your system falls outside that envelope, GPU clusters, real-time systems with strict latency requirements, hybrid on-premise and cloud deployments, hardware-in-the-loop testing, a general-purpose platform may simply not fit.</p>
<p>Shoehorning an unusual workload into a PaaS often produces more friction than building narrow, purpose-built deployment tooling around the specific constraints you actually have.</p>
<p>The common thread across all three cases is specificity. The teams that are right to own their pipelines can usually state clearly why a platform doesn't fit. If the answer is "we've always done it this way" or "we like having control," that's worth questioning. If the answer is "our compliance requirements mandate X" or "we sell this," that's a reason.</p>
<h2 id="heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</h2>
<p>A PaaS does not make any of these steps disappear. The build still runs. Artefacts still get stored. Migrations still execute, health checks still poll, and traffic still shifts one copy at a time. Abstracting these mechanics does not eliminate them. It standardizes them, and pushes their maintenance onto a team whose entire product is deployment.</p>
<p>That is the question every product team should now ask plainly: why are we still building and operating this machinery ourselves? A decade ago, a custom pipeline was unavoidable. Today it is a choice, and for most teams it is the wrong one. Every hour spent debugging a flaky build agent, tuning a health check timeout, or patching orchestration scripts is an hour taken from the product your customers actually pay for. The pipeline does not differentiate you. It cannot. Your competitors' deploys work the same way yours do.</p>
<p>Know how the chain works, because on-call at 2 a.m. demands it. But knowing how it works is not a reason to own it. "We built our own deployment system" is not a badge of honor anymore. It is an admission that your team maintains a second product with no customers. Unless deployment infrastructure is your business, hand the machinery to a platform, and put your engineers back on the work only they can do.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create a Marketing Landing Page Using shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most marketing landing pages start with the same problem: you're staring at a blank screen and rebuilding sections you've already created countless times. A hero section, feature grid, testimonials, p ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-marketing-landing-page-using-shadcn-ui/</link>
                <guid isPermaLink="false">6a6c8095409fd0bcb0afd1c6</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Fri, 31 Jul 2026 11:01:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/82bb4c21-aebe-48c4-9a66-f013011223f4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most marketing landing pages start with the same problem: you're staring at a blank screen and rebuilding sections you've already created countless times.</p>
<p>A hero section, feature grid, testimonials, pricing, FAQ, and footer are common building blocks, yet developers often spend hours recreating them for every new project.</p>
<p>In this guide, you'll learn how to build a modern marketing landing page using Next.js, shadcn/ui, Tailwind CSS, and reusable Shadcn blocks. Instead of building every section from scratch, you'll assemble a production-ready page, customize it to match your brand, and finish with a foundation that's ready for real-world projects.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-we-building">What Are We Building?</a></p>
</li>
<li><p><a href="#heading-project-setup-with-base-ui-using-the-shadcn-preset">Project Setup with Base UI Using the Shadcn Preset</a></p>
</li>
<li><p><a href="#heading-two-ways-to-build-your-marketing-landing-page">Two Ways to Build Your Marketing Landing Page</a></p>
</li>
<li><p><a href="#heading-option-1-build-using-the-cli">Option 1: Build Using the CLI</a></p>
</li>
<li><p><a href="#heading-option-2-build-using-the-mcp-server">Option 2: Build Using the MCP Server</a></p>
</li>
<li><p><a href="#heading-how-to-optimize-your-landing-page">How to Optimize Your Landing Page</a></p>
</li>
<li><p><a href="#heading-how-to-expand-your-marketing-website">How to Expand Your Marketing Website</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview:</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed</p>
</li>
<li><p>shadcn/ui initialized in your project (<code>npx shadcn@latest init</code>)</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>If you haven't initialized shadcn/ui yet, run <code>npx shadcn@latest init</code> in your project root and follow the prompts before continuing.</p>
<h2 id="heading-what-are-we-building"><strong>What Are We Building?</strong></h2>
<p>In this tutorial, we'll build a modern marketing landing page using production-ready blocks from <a href="https://shadcnspace.com/">Shadcn Space</a>. Instead of designing and developing every section from scratch, we'll assemble a complete landing page using reusable shadcn/ui blocks and customize them to fit our brand and product.</p>
<p>You can use the same approach to create landing pages for SaaS products, AI tools, startups, agencies, developer tools, portfolios, and many other types of websites. Since every block is built with React, Tailwind CSS, and shadcn/ui, you have full control over the code and can easily modify the content, layout, and styling.</p>
<h3 id="heading-why-build-with-shadcn-space">Why Build with Shadcn Space?</h3>
<p>Creating a professional marketing website typically involves designing multiple sections that work together to tell your product's story and guide visitors to take action.</p>
<p>But instead of building every section from scratch, you can start with production-ready blocks that are easy to customize.</p>
<p>This lets you build marketing websites faster with reusable blocks (and you can also mix and match blocks to create unique page layouts). It also gives you full ownership of your clean React and Tailwind CSS code. And overall, you save development time without sacrificing flexibility.</p>
<h3 id="heading-sections-well-build">Sections We'll Build</h3>
<p>We'll build our marketing landing page using the following sections:</p>
<ul>
<li><p>Hero section with a compelling headline, call-to-action, and trusted-by logos.</p>
</li>
<li><p>Features section to highlight your product's key capabilities.</p>
</li>
<li><p>Product Showcase &amp; Benefits section to demonstrate your product and communicate its value.</p>
</li>
<li><p>Testimonials section to build credibility with customer feedback.</p>
</li>
<li><p>Pricing section to present your plans clearly.</p>
</li>
<li><p>FAQ section answers common questions and reduces friction.</p>
</li>
<li><p>Call-to-Action section to encourage visitors to get started.</p>
</li>
<li><p>Footer with navigation and important links.</p>
</li>
</ul>
<h3 id="heading-final-page-structure">Final Page Structure</h3>
<p>Our landing page will have this structure:</p>
<pre><code class="language-javascript">&lt;main&gt;
  {/* 1. Hero section + Trusted by / Logo cloud */}
  &lt;AgencyHeroSection /&gt;

  {/* 2. Features section */}
  &lt;Feature01 /&gt;

  {/* 3. Product showcase &amp; Benefits */}
  &lt;AboutAndStats01 /&gt;

  {/* 4. Testimonials */}
  &lt;Testimonials /&gt;

  {/* 5. Pricing section */}
  &lt;Pricing /&gt;

  {/* 6. FAQ section */}
  &lt;Faq /&gt;

  {/* 7. Call-to-action section */}
  &lt;CTA /&gt;

  {/* Footer */}
  &lt;Footer /&gt;
&lt;/main&gt;
</code></pre>
<p>Each section will be installed from the Shadcn Space registry and customized directly inside our project. By the end of this tutorial, you'll have a fully responsive marketing landing page built with Next.js, Tailwind CSS, and shadcn/ui that you can adapt for your own product or business.</p>
<h2 id="heading-project-setup-with-base-ui-using-the-shadcn-preset"><strong>Project Setup with Base UI Using the Shadcn Preset</strong></h2>
<p>Since Shadcn Space blocks are built using Base UI primitives, we'll create our project using the Base UI preset instead of the default Radix setup.</p>
<p>This ensures that our landing page uses the same foundation as the blocks we're going to install.</p>
<h3 id="heading-1-create-the-project-with-base-ui">1. Create the Project with Base UI</h3>
<p>Run the following command:</p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest init --preset b0 --template next
</code></pre>
<p>This command does a few important things:</p>
<ul>
<li><p>Creates a Next.js project</p>
</li>
<li><p>Configures Tailwind CSS</p>
</li>
<li><p>Sets up Base UI as the component foundation</p>
</li>
<li><p>Uses the Nova style preset</p>
</li>
<li><p>Configures Lucide icons</p>
</li>
<li><p>Uses Inter font</p>
</li>
<li><p>Applies neutral theme tokens</p>
</li>
</ul>
<p>You now have a Base UI-powered Next.js project ready for building your landing page.</p>
<h3 id="heading-2-add-the-shadcn-space-registry">2. Add the Shadcn Space Registry</h3>
<p>Open your <code>components.json</code> and add the following registry configuration:</p>
<pre><code class="language-javascript">{
  "registries": {
    "@shadcn-space": {
      "url": "https://shadcnspace.com/r/{name}.json",
    }
  }
}
</code></pre>
<p>This tells the CLI where to fetch components and blocks from the registry.</p>
<p>For more information about how to use it in your project, <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli">check out the docs</a>.</p>
<h2 id="heading-two-ways-to-build-your-marketing-landing-page"><strong>Two Ways to Build Your Marketing Landing Page</strong></h2>
<p>Now that your project is set up with shadcn/ui, it's time to start building the marketing landing page.</p>
<p>You can install production-ready blocks directly into your project in two different ways:</p>
<ul>
<li><p>Using the CLI</p>
</li>
<li><p>Using the MCP Server inside your AI-powered editor</p>
</li>
</ul>
<p>Both approaches install the actual React and Tailwind CSS source code into your project, giving you complete control over customization. The only difference is how you discover and install the blocks.</p>
<h2 id="heading-option-1-build-using-the-cli">Option 1: Build Using the CLI</h2>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/n6dvjVxy02U" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>The CLI is the fastest way to browse the registry and install individual blocks into your project. It gives you full control over which sections you want to use and how you customize them.</p>
<h3 id="heading-step-1-browse-marketing-blocks">Step 1: Browse Marketing Blocks</h3>
<p>Visit the block registry and explore the available marketing blocks.</p>
<p>Let's review the sections we'll use for this tutorial:</p>
<ul>
<li><p>Hero section with a call-to-action and trusted-by logos</p>
</li>
<li><p>Features section</p>
</li>
<li><p>Product showcase &amp; benefits section</p>
</li>
<li><p>Testimonials section</p>
</li>
<li><p>Pricing section</p>
</li>
<li><p>FAQ section</p>
</li>
<li><p>Call-to-action section</p>
</li>
<li><p>Footer</p>
</li>
</ul>
<p>Choose the blocks that best match the design and style of your website. Since every block is fully customizable, you can easily update the content, colors, spacing, and layout to match your brand.</p>
<p>In the following sections, we'll install each block and customize them.</p>
<h3 id="heading-step-2-install-selected-blocks">Step 2: Install Selected Blocks</h3>
<p>Once you find a block you like, install it using the CLI:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/{block-name}
</code></pre>
<p>Each command downloads the block, places it inside <code>components/shadcn-space/blocks</code>, and installs the required dependencies.</p>
<p>Now your folder might look like this:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    blocks/
      about-us-section-01/
      hero-01/
      features-01/
      pricing-01/
      testimonial-01/
      faq-01/
      cta-01/
      footer-01/
</code></pre>
<p><strong>Note:</strong> I've used the first block from each section in this tutorial. You can choose any other <a href="https://shadcnspace.com/blocks"><strong>shadcn block</strong></a> that suits best according to your needs.</p>
<h3 id="heading-step-3-add-a-hero-section">Step 3: Add a Hero Section</h3>
<p>Every great marketing landing page starts with a strong hero section. It's the first thing visitors see, so it should clearly communicate what your product does, who it's for, and encourage users to take action.</p>
<p>Instead of building the section from scratch, we'll install a production-ready Hero block and customize it to match our landing page.</p>
<h4 id="heading-1-install-the-hero-block">1. Install the Hero Block</h4>
<p>Run the following CLI command to add the Hero block to your project:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/hero-01
</code></pre>
<p>The CLI will automatically download the Hero block source code, add the component to your project, and install any required dependencies.</p>
<p>After the installation completes, you should see the following directory structure:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    blocks/
      hero-01/
        index.tsx
</code></pre>
<p>You can now open the component and customize the heading, description, call-to-action buttons, images, and other content to match your product and branding.</p>
<p><strong>2. Understand the Hero Block Structure</strong></p>
<p>Once the installation is complete, open the Hero block located at:</p>
<pre><code class="language-javascript">components/shadcn-space/blocks/hero-01/index.tsx
</code></pre>
<p>You'll see a component similar to the following:</p>
<pre><code class="language-javascript">import HeroSection from "@/components/shadcn-space/blocks/hero-01/hero";
import type { NavigationSection } from "@/components/shadcn-space/blocks/hero-01/header";
import Header from "@/components/shadcn-space/blocks/hero-01/header";
import BrandSlider, { BrandList } from "@/components/shadcn-space/blocks/hero-01/brand-slider";
import type { AvatarList } from "@/components/shadcn-space/blocks/hero-01/hero";

export default function AgencyHeroSection() {
  const avatarList: AvatarList[] = [...];
  const navigationData: NavigationSection[] = [...];
  const brandList: BrandList[] = [...];

  return (
    &lt;div className="relative"&gt;
      &lt;Header navigationData={navigationData} /&gt;
      &lt;main&gt;
        &lt;HeroSection avatarList={avatarList} /&gt;
        &lt;BrandSlider brandList={brandList} /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>What Should You Notice?</strong></p>
<p>Before making any changes, take a moment to understand how the block is organized. Rather than being a single large component, it's composed of smaller, reusable components that work together.</p>
<p>In this example:</p>
<ul>
<li><p>The <code>Header</code> component renders the navigation and receives its menu items through the <code>navigationData</code> array.</p>
</li>
<li><p>The <code>HeroSection</code> component contains the main headline, description, call-to-action buttons, and social proof, while the <code>avatarList</code> provides the data displayed in the hero.</p>
</li>
<li><p>The <code>BrandSlider</code> component displays the company logos using the <code>brandList</code> array.</p>
</li>
</ul>
<p>This separation keeps the code modular and makes each part of the landing page easier to customize or replace independently.</p>
<p><strong>Why This Matters?</strong></p>
<p>Because the block is copied directly into your project, you're working with standard React components instead of a compiled package. Every file is fully editable, allowing you to understand how the section is built and modify it to suit your own requirements.</p>
<p>For example, you can update the navigation links, replace the placeholder content with your own branding, add additional sections, integrate custom functionality, or adjust the styling using Tailwind CSS classes. Since everything lives inside your codebase, you're free to restructure the component however you like without being locked into predefined APIs or abstractions.</p>
<h4 id="heading-3-render-the-hero-section">3. Render the Hero Section</h4>
<p>Now that the Hero block has been installed, it's time to display it on the page.</p>
<p>Import the component into your <code>app/page.tsx</code> file:</p>
<pre><code class="language-javascript">import AgencyHeroSection from "@/components/shadcn-space/blocks/hero-01";

export default function Page() {
  return (
    &lt;AgencyHeroSection /&gt;
  );
}
</code></pre>
<p>Save the file and start your development server if it isn't already running. When you open the application in your browser, you'll see the Hero section rendered as the first part of your marketing landing page.</p>
<p>With the Hero section in place, we've completed the first building block of our landing page. Next, we'll continue by adding the remaining sections to create a complete marketing website.</p>
<h3 id="heading-install-the-remaining-blocks">Install the Remaining Blocks</h3>
<p>Now that you've added and rendered the Hero section, let's install the remaining blocks required for our marketing landing page.</p>
<p>Run the following command to install all the remaining sections at once:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/feature-01 @shadcn-space/about-us-section-01 @shadcn-space/testimonial-01 @shadcn-space/pricing-01 @shadcn-space/faq-01 @shadcn-space/cta-01  @shadcn-space/footer-01
</code></pre>
<p><strong>Note:</strong> If you're using Windows Command Prompt or PowerShell, run the command on a single line instead of using <code>\</code> for line continuation.</p>
<p>After the installation is complete, update your <code>app/page.tsx</code> by importing the newly added blocks and rendering them in the following order:</p>
<pre><code class="language-javascript">import AgencyHeroSection from "@/components/shadcn-space/blocks/hero-01";
import AboutAndStats01 from "@/components/shadcn-space/blocks/about-us-01";
import Feature01 from "@/components/shadcn-space/blocks/feature-01";
import Pricing from "@/components/shadcn-space/blocks/pricing-01/pricing";
import Testimonials from "@/components/shadcn-space/blocks/testimonial-01/testimonial";
import Faq from "@/components/shadcn-space/blocks/faq-01/faq";
import CTA from "@/components/shadcn-space/blocks/cta-01/cta";
import Footer from "@/components/shadcn-space/blocks/footer-01/footer";


export const metadata = {
  title: "Acme Agency – Innovative Digital Solutions",
  description:
    "We craft immersive digital experiences for bold brands. Explore our services, pricing, and success stories.",
};


export default function Page() {
  return (
    &lt;main&gt;
      {/* 1. Hero section + Trusted by / logo cloud */}
      &lt;AgencyHeroSection /&gt;


      {/* 2. Features section */}
      &lt;Feature01 /&gt;


      {/* 3. Product showcase &amp; Benefits section (Using About/Stats as a placeholder for these) */}
      &lt;AboutAndStats01 /&gt;


      {/* 4. Testimonials */}
      &lt;Testimonials /&gt;


      {/* 5. Pricing section */}
      &lt;Pricing /&gt;


      {/* 6. FAQ section */}
      &lt;Faq /&gt;


      {/* 7. Call-to-action section */}
      &lt;CTA /&gt;


      {/* Footer */}
      &lt;Footer /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>That's it! Your complete marketing landing page is now assembled. You can start customizing the content, images, colors, and layout of each section to match your product and brand.</p>
<h3 id="heading-customize-the-marketing-landing-page"><strong>Customize the Marketing Landing Page</strong></h3>
<p>At this point, the overall structure of your marketing landing page is complete. The next step is to personalize each section so it reflects your product, brand, and messaging instead of the default placeholder content.</p>
<p>One of the biggest advantages of working with reusable React components is that every section can be customized independently. You can update the content, replace images, adjust layouts, and refine the styling without rebuilding the page from scratch.</p>
<p>The following examples show how the default blocks can be transformed into a polished marketing website.</p>
<p><strong>Customize the Hero Section</strong></p>
<p>The Hero section is the first thing visitors see, so it's the most important place to communicate your product's value. Replace the placeholder headline, supporting text, call-to-action buttons, and trusted brand logos with content that represents your own business.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f29da0e0-0da0-482e-bd29-35f3c3c696db.png" alt="Customize the Hero Section Before Using MCP" style="display:block;margin:0 auto" width="1919" height="850" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/1ec0dad0-0dee-448f-aed2-136e310bbc5b.png" alt="Customize the Hero Section After Using MCP" style="display:block;margin:0 auto" width="1906" height="865" loading="lazy">

<p>Notice how the customized version immediately establishes the product's identity through updated messaging, branding, imagery, and call-to-action buttons.</p>
<p><strong>Customize the Features Section</strong></p>
<p>The Features section should explain what your product offers and why it stands out. Replace the sample feature cards with capabilities that highlight your product's most valuable functionality.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/b44dfc47-8603-4a1c-847e-b608b84c7723.png" alt="Customize the Features Section Before Using MCP" style="display:block;margin:0 auto" width="1474" height="866" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/dbc1d529-88ac-42f2-a5d1-0c0348165944.png" alt="Customize the Features Section After Using MCP" style="display:block;margin:0 auto" width="1525" height="936" loading="lazy">

<p>Updating the feature titles, descriptions, and icons makes the section more relevant to your audience while reinforcing your product's key selling points.</p>
<p><strong>Customize the Pricing Section</strong></p>
<p>Your pricing section should clearly communicate the plans you offer and help visitors choose the option that best fits their needs. Replace the default plans, pricing, feature lists, and button labels with information that matches your business model.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/7e053524-b281-4ee4-9afe-f0ee1ad76d48.png" alt="Customize the Pricing Section Before Using MCP" style="display:block;margin:0 auto" width="1631" height="722" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/5ae201a8-46a9-4ca4-909c-7e1ecc5c4077.png" alt="Customize the Pricing Section After Using MCP" style="display:block;margin:0 auto" width="1631" height="817" loading="lazy">

<p>A customized pricing section builds trust by presenting accurate information while making it easier for potential customers to compare plans.</p>
<p><strong>Customize the FAQ Section</strong></p>
<p>The FAQ section is a great opportunity to answer common questions before visitors contact your team. Replace the placeholder questions with answers related to your product, pricing, integrations, support, or onboarding process.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/4b161671-61f0-45c0-8266-3f39015958ec.png" alt="Customize the FAQ Section Before Using MCP" style="display:block;margin:0 auto" width="1650" height="883" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/8f68f5a8-ad09-4021-843a-58329d0a08ce.png" alt="Customize the FAQ Section After Using MCP" style="display:block;margin:0 auto" width="1638" height="785" loading="lazy">

<p>Tailoring the FAQ to your product helps reduce uncertainty, improves the user experience, and can answer many questions before a customer reaches out.</p>
<p><strong>The Result</strong></p>
<p>With just a few content updates, the default blocks evolve into a professional marketing landing page tailored to your brand. Since every section is built with reusable React components, you can continue refining the design, adjusting layouts, and adding new content as your product grows without changing the overall page structure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/3759ae7a-d244-487d-8354-d9dd42570bfd.gif" alt="Full Preview of the Landing Page" style="display:block;margin:0 auto" width="1909" height="840" loading="lazy">

<h2 id="heading-option-2-build-using-the-mcp-server"><strong>Option 2: Build Using the MCP Server</strong></h2>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/mMlxAmJlbMI" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>If you prefer a faster workflow, you can use the MCP Server to generate your landing page directly inside your editor. Instead of manually browsing and installing individual blocks, you simply describe the page you want to build, and the MCP Server assembles an initial version for you.</p>
<p>The MCP Server works with supported editors like Antigravity, VS Code, Cursor, Windsurf, and other MCP-compatible editors.</p>
<h3 id="heading-step-1-install-the-mcp-server">Step 1: Install the MCP Server</h3>
<p><strong>Quick Installation</strong>:</p>
<p>The fastest way to get started. Choose your package manager and run the command corresponding to your client:</p>
<p><strong>For Claude Code</strong>:</p>
<pre><code class="language-javascript">claude mcp add shadcnspace-mcp -- npx -y shadcnspace-mcp@latest
</code></pre>
<p><strong>For Others</strong>:</p>
<pre><code class="language-javascript">npx shadcnspace-cli install &lt;client&gt;
</code></pre>
<p>Replace <code>&lt;client&gt;</code> with <strong>cursor, antigravity, vscode,</strong> or <strong>windsurf</strong>.</p>
<p><strong>Manual Installation For VS Code:</strong></p>
<pre><code class="language-javascript">{
  "servers": {
    "shadcnspace-mcp": {
      "command": "npx",
      "args": ["-y", "shadcnspace-mcp@latest"]
    }
  }
}
</code></pre>
<ul>
<li><p>Open .vscode/mcp.json.</p>
</li>
<li><p>Click Start next to the Shadcn Space MCP server</p>
</li>
</ul>
<p>For a detailed guide, follow the <a href="https://shadcnspace.com/docs/getting-started/mcp-server-docs"><strong>MCP Server documentation</strong></a> to install it for your preferred editor.</p>
<p>Once the installation is complete, restart your editor to enable the MCP connection.</p>
<h3 id="heading-step-2-open-the-ai-chat">Step 2: Open the AI Chat</h3>
<p>Open the AI chat panel inside your editor and describe the landing page you'd like to create.</p>
<p>For example:</p>
<pre><code class="language-javascript">Create a modern marketing landing page for an AI SaaS product.

Use Shadcn Space blocks and include:

- Hero section
- Features section
- Product showcase &amp; benefits
- Testimonials
- Pricing
- FAQ
- Call-to-action
- Footer

Use a clean, modern, and responsive design.
</code></pre>
<p>Feel free to replace the product description with your own and customize as you like.</p>
<h3 id="heading-step-3-generate-the-landing-page">Step 3: Generate the Landing Page</h3>
<p>After receiving your prompt, the MCP Server analyzes your requirements and selects the most suitable blocks for your landing page. It automatically assembles the page using a combination of reusable sections, giving you a working layout in just a few moments.</p>
<p>The generated sections are added directly to your project as standard React components. Since everything is real source code, you can customize every part of the page: update the content, replace images, modify the layout, adjust spacing, or restyle components using Tailwind CSS.</p>
<p>The MCP Server simply speeds up the initial setup, while giving you complete control over the final implementation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/2247726d-12ab-47d8-bd7a-49b025ea0eb5.gif" alt="How to Generate the Landing Page" style="display:block;margin:0 auto" width="1909" height="913" loading="lazy">

<h2 id="heading-how-to-optimize-your-landing-page">How to Optimize Your Landing Page</h2>
<p>Building a visually appealing landing page is only the first step. Before publishing your website, it's worth spending some time optimizing it for performance, search engines, and user experience.</p>
<p>A fast, responsive landing page not only feels more polished but also helps improve engagement and conversion rates. Fortunately, Next.js provides several built-in features that make these optimizations straightforward.</p>
<h3 id="heading-optimize-images-with-nextjs">Optimize Images with Next.js</h3>
<p>Images are often the largest assets on a marketing website, so optimizing them can significantly improve loading performance. If you're using Next.js, prefer the built-in <code>next/image</code> component instead of the standard HTML image tag. It automatically serves appropriately sized images, supports modern image formats, and reduces layout shifts as the page loads.</p>
<p>Before adding screenshots or illustrations to your project, make sure they're compressed and sized appropriately. Well-optimized images create a smoother browsing experience across desktop and mobile devices while also contributing to better Core Web Vitals.</p>
<h3 id="heading-improve-seo-for-your-landing-page">Improve SEO for Your Landing Page</h3>
<p>A well-designed landing page is only effective if people can discover it. Search engine optimization starts with creating meaningful content that clearly communicates what your product offers. Choose a descriptive page title, write a concise meta description, and organize your content using logical headings.</p>
<p>Your primary keyword should appear naturally throughout the page without forcing it into every paragraph. Focus on writing for your audience first, then structure the content in a way that search engines can easily understand. Combining valuable content with a clear page hierarchy gives your landing page the best chance of ranking for relevant searches.</p>
<h3 id="heading-add-metadata-and-open-graph-images">Add Metadata and Open Graph Images</h3>
<p>When someone shares your landing page on social media or in a messaging application, the preview is generated from your page's metadata. Configuring Open Graph and Twitter metadata allows you to control the title, description, and preview image that appear when your website is shared.</p>
<p>A custom preview image that reflects your branding makes your links look more professional and can encourage more people to click through. Taking a few minutes to configure these settings helps create a more polished experience whenever your content is shared online.</p>
<h3 id="heading-optimize-performance">Optimize Performance</h3>
<p>Performance plays a major role in how visitors perceive your website. A page that loads quickly feels more responsive and encourages users to continue exploring your content. As you customize your landing page, keep unnecessary JavaScript to a minimum, optimize static assets, and avoid loading resources that aren't immediately needed.</p>
<p>Even small improvements, such as reducing image sizes or simplifying animations, can noticeably improve loading speed. Before deploying your project, test the page under different network conditions to ensure it performs well for all visitors.</p>
<h3 id="heading-improve-core-web-vitals">Improve Core Web Vitals</h3>
<p>Core Web Vitals are Google's metrics for measuring real-world user experience. They evaluate how quickly your main content appears, how responsive the page feels during interactions, and whether elements remain stable as the page loads. Monitoring these metrics throughout development helps identify potential issues before they affect users.</p>
<p>Tools such as Lighthouse and PageSpeed Insights provide detailed reports that can help you improve loading performance and responsiveness. A landing page with strong Core Web Vitals not only creates a better experience for visitors but can also contribute to improved search rankings.</p>
<hr>
<h2 id="heading-how-to-expand-your-marketing-website"><strong>How to Expand Your Marketing Website</strong></h2>
<p>A landing page is often just the beginning of a complete marketing website. As your product grows, you'll likely need additional pages that provide more information, improve navigation, and create a better experience for your visitors. Common additions include Blog, Blog Details, Pricing, FAQ, Changelog, Contact, Integration, Error, and About Us pages.</p>
<p>Building these pages with a consistent design system helps maintain a unified look and feel across your entire website while reducing development time. Reusing layouts and components also makes your project easier to maintain as it evolves.</p>
<p>If you're looking to expand your website beyond a single landing page, explore the collection of production-ready <a href="https://shadcnspace.com/pages"><strong>Shadcn website pages</strong></a>, built with React, Next.js, Tailwind CSS, and shadcn/ui.</p>
<h2 id="heading-live-preview"><strong>Live Preview:</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/a189f09d-550d-4c7f-b20a-477f5bc1bfa0.gif" alt="How to Expand Your Marketing Website" style="display:block;margin:0 auto" width="1909" height="913" loading="lazy">

<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this tutorial, we built a complete marketing landing page using Next.js, shadcn/ui, Tailwind CSS, and reusable Shadcn Space blocks. Starting from a fresh project, we assembled a production-ready page by combining sections such as the Hero, Features, Product Showcase, Testimonials, Pricing, FAQ, Call-to-Action, and Footer.</p>
<p>Because every block is added as standard React source code, you're free to customize the content, layout, styling, and functionality to match your own product and branding. Whether you're building a SaaS application, a startup website, an AI product, an agency site, or a developer tool, the same approach can be adapted to your requirements.</p>
<p>A marketing landing page is just the foundation of your online presence. As your product grows, you can continue expanding your website with additional marketing pages while maintaining a consistent design system and development workflow.</p>
<p>I hope this guide has helped you understand how to quickly build a modern, responsive marketing landing page using reusable components. Feel free to experiment with different block combinations, personalize the design, and create a website that best represents your product.</p>
<p><strong>Appreciation</strong>: I wrote this article with the help of Mihir Koshti (Sr. Full Stack Developer) – <a href="https://www.linkedin.com/in/mihir-koshti/">Connect on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF Blur Tool Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Many PDF documents contain information that shouldn't be shared publicly. Personal details, financial figures, signatures, addresses, account numbers, employee information, or confidential business da ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-blur-tool-javascript/</link>
                <guid isPermaLink="false">6a63cec8c741f882378e9b06</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Online PDF Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Fri, 24 Jul 2026 20:44:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ca664176-3589-4e16-b95e-506fa53bcfce.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Many PDF documents contain information that shouldn't be shared publicly. Personal details, financial figures, signatures, addresses, account numbers, employee information, or confidential business data often need to be hidden before a file is sent to someone else.</p>
<p>A PDF Blur Tool makes this process simple. Instead of permanently removing content, it places a blur effect over selected areas of a PDF so sensitive information becomes difficult to read while the rest of the document remains unchanged.</p>
<p>In this tutorial, you'll build a browser-based PDF Blur Tool using JavaScript. Users will be able to upload a PDF, preview every page, draw blur boxes over sensitive content, adjust the blur intensity, apply the blur to selected pages, preview the final result, and download the processed PDF, all without uploading files to a server.</p>
<p>We'll use PDF.js to render PDF pages inside the browser, HTML Canvas to create and manage blur regions, and PDF-lib to generate the final blurred PDF.</p>
<p>By the end of this tutorial, you'll have a fully functional client-side PDF editing tool similar to the one available on my site, AllInOneTools.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/76925a1f-2b94-4b5b-923c-0252d1703b22.png" alt="allinonetools - pdf tools- blur pdf documents" style="display:block;margin:0 auto" width="905" height="282" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-blur-tool-does-and-how-it-works">What This PDF Blur Tool Does and How It Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-creating-blur-regions">Creating Blur Regions</a></p>
</li>
<li><p><a href="#heading-applying-blur-to-pages">Applying Blur to Pages</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-blur-tool-works">Demo: How the PDF Blur Tool Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-blur-tool-does-and-how-it-works">What This PDF Blur Tool Does and How It Works</h2>
<p>A PDF Blur Tool helps protect sensitive information before a document is shared. Instead of editing or deleting the original content, it applies a visual blur effect over selected areas so confidential information becomes unreadable while the rest of the document remains unchanged.</p>
<p>This approach is useful for hiding personal details, financial information, account numbers, signatures, addresses, faces, or any other private content that shouldn't be visible in the final document.</p>
<p>In this project, users can upload a PDF directly from their browser, preview every page, and draw one or more blur regions over the areas they want to hide. The tool also allows users to adjust the blur intensity, blur either selected areas or entire pages, apply the effect to the current page, all pages, or specific page ranges, preview the completed document, rename the output file, and download the final PDF.</p>
<p>Because everything runs inside the browser, no files are uploaded to a server, helping maintain document privacy.</p>
<p>Behind the scenes, the application first renders each PDF page onto an HTML canvas using PDF.js. Rather than modifying the original PDF immediately, it records the position, size, page number, and blur intensity for every blur region that the user creates.</p>
<p>When the user clicks Apply &amp; Finalize, those stored regions are converted from browser coordinates into actual PDF page coordinates. The selected blur effect is then applied to the rendered page, and PDF-lib generates a new PDF containing the blurred content while preserving the rest of the document.</p>
<p>This workflow provides an interactive editing experience while keeping the original PDF unchanged until the final document is generated.</p>
<p>For example, each blur region can be represented as an object like this:</p>
<pre><code class="language-javascript">const blurRegion = {

    page: 2,

    x: 180,

    y: 240,

    width: 260,

    height: 90,

    intensity: 6

};
</code></pre>
<p>Each object stores all the information required to recreate the blur effect during the final PDF generation process.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing any code, let's create a simple project structure for our PDF Blur Tool.</p>
<p>We'll use plain HTML, CSS, and JavaScript, along with two libraries:</p>
<ul>
<li><p><strong>PDF.js</strong> for rendering PDF pages inside the browser.</p>
</li>
<li><p><strong>PDF-lib</strong> for generating the final blurred PDF.</p>
</li>
</ul>
<p>Our project structure looks like this:</p>
<pre><code class="language-text">pdf-blur-tool/

│── index.html
│── style.css
│── script.js
│── pdf.worker.min.js
│── assets/
</code></pre>
<p>Keeping the project simple makes it easier to understand how each part works.</p>
<p>Add PDF.js and PDF-lib before loading your own JavaScript.</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;

&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;

&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc =
    "pdf.worker.min.js";
</code></pre>
<p>The worker processes PDF rendering in a background thread, helping keep the interface responsive while pages are rendered.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application consists of four main sections:</p>
<ul>
<li><p>Upload area</p>
</li>
<li><p>PDF preview</p>
</li>
<li><p>Blur settings panel</p>
</li>
<li><p>Final download section</p>
</li>
</ul>
<p>Create the basic layout:</p>
<pre><code class="language-html">&lt;div id="uploadSection"&gt;&lt;/div&gt;

&lt;div id="editorSection" hidden&gt;

    &lt;div id="pdfPreview"&gt;&lt;/div&gt;

    &lt;aside id="blurSettings"&gt;&lt;/aside&gt;

&lt;/div&gt;

&lt;div id="resultSection" hidden&gt;&lt;/div&gt;
</code></pre>
<p>Initially, only the upload section is visible.</p>
<p>After a PDF is selected, the editor becomes visible.</p>
<h3 id="heading-selecting-dom-elements">Selecting DOM Elements</h3>
<p>Create references to the elements used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection =
    document.getElementById(
        "uploadSection"
    );

const editorSection =
    document.getElementById(
        "editorSection"
    );

const resultSection =
    document.getElementById(
        "resultSection"
    );

const fileInput =
    document.getElementById(
        "pdfInput"
    );

const pdfCanvas =
    document.getElementById(
        "pdfCanvas"
    );

const canvasContext =
    pdfCanvas.getContext("2d");
</code></pre>
<p>These references allow the application to switch between the upload, editing, and download stages.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload section accepts both drag-and-drop and traditional file selection.</p>
<p>When a PDF is chosen, verify that it's actually a PDF before continuing.</p>
<pre><code class="language-javascript">async function handlePdfUpload(
    file
) {

    if (
        !file ||
        file.type !==
        "application/pdf"
    ) {

        alert(
            "Please select a PDF file."
        );

        return;

    }

    await loadPdf(file);

}
</code></pre>
<p>If validation succeeds, the document is loaded into memory.</p>
<h3 id="heading-reading-the-pdf">Reading the PDF</h3>
<p>Use the File API to convert the uploaded file into an ArrayBuffer.</p>
<pre><code class="language-javascript">async function loadPdf(
    file
) {

    const bytes =
        await file.arrayBuffer();

    pdfDocument =
        await pdfjsLib
            .getDocument({
                data: bytes
            })
            .promise;

    currentPage = 1;

    await renderPage(
        currentPage
    );

}
</code></pre>
<p>The uploaded bytes will also be reused later when generating the blurred PDF.</p>
<h3 id="heading-rendering-the-first-page">Rendering the First Page</h3>
<p>PDF.js renders each page onto an HTML canvas.</p>
<p>Start by retrieving the requested page.</p>
<pre><code class="language-javascript">async function renderPage(
    pageNumber
) {

    const page =
        await pdfDocument
            .getPage(
                pageNumber
            );

    const viewport =
        page.getViewport({
            scale: 1.5
        });
</code></pre>
<p>Resize the canvas to match the page dimensions.</p>
<pre><code class="language-javascript">pdfCanvas.width =
    viewport.width;

pdfCanvas.height =
    viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({

    canvasContext,

    viewport

}).promise;
</code></pre>
<p>After rendering finishes, the PDF page becomes visible inside the editor.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/0dbda32b-6bfa-4403-97bd-288dfe808239.png" alt=" Uploaded PDF displayed in the preview area with page navigation controls." style="display:block;margin:0 auto" width="1256" height="515" loading="lazy">

<h3 id="heading-creating-page-navigation">Creating Page Navigation</h3>
<p>Most PDF documents contain multiple pages.</p>
<p>Allow users to move between pages using Previous and Next buttons.</p>
<pre><code class="language-javascript">let currentPage = 1;

let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton
.addEventListener(
    "click",
    async () =&gt; {

        if (
            currentPage === 1
        ) {

            return;

        }

        currentPage--;

        await renderPage(
            currentPage
        );

    }
);
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton
.addEventListener(
    "click",
    async () =&gt; {

        if (
            currentPage ===
            pdfDocument.numPages
        ) {

            return;

        }

        currentPage++;

        await renderPage(
            currentPage
        );

    }
);
</code></pre>
<p>Update the page counter whenever the current page changes.</p>
<pre><code class="language-javascript">pageIndicator.textContent =
    `Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>This provides users with clear feedback while navigating large PDF documents.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c99410bc-7d7c-411f-a8f1-8fd9b1aaf3b8.png" alt=" PDF preview with Previous and Next buttons for navigating between pages." style="display:block;margin:0 auto" width="546" height="818" loading="lazy">

<h3 id="heading-preparing-for-blur-editing">Preparing for Blur Editing</h3>
<p>Once the current page is rendered, the application prepares a transparent layer above the PDF canvas.</p>
<p>This overlay captures mouse interactions without modifying the original page preview.</p>
<p>Create the overlay.</p>
<pre><code class="language-html">&lt;canvas
    id="overlayCanvas"&gt;
&lt;/canvas&gt;
</code></pre>
<p>Match its size to the PDF preview.</p>
<pre><code class="language-javascript">overlayCanvas.width =
    pdfCanvas.width;

overlayCanvas.height =
    pdfCanvas.height;
</code></pre>
<p>Later in the tutorial, this overlay will allow users to draw blur regions while keeping the underlying PDF page untouched.</p>
<h3 id="heading-showing-the-editor">Showing the Editor</h3>
<p>After the first page finishes rendering, switch from the upload screen to the editor interface.</p>
<pre><code class="language-javascript">uploadSection.hidden =
    true;

editorSection.hidden =
    false;
</code></pre>
<p>Users can now preview the document, navigate between pages, and begin selecting areas that should be blurred.</p>
<h2 id="heading-creating-blur-regions">Creating Blur Regions</h2>
<p>Now that the PDF preview is working, we can build the most important feature of the application: allowing users to blur sensitive information.</p>
<p>Instead of editing the PDF immediately, users first draw one or more blur regions over the page preview.</p>
<p>Each region stores its own position, size, and blur intensity. These regions are later converted into actual PDF coordinates during final processing.</p>
<h3 id="heading-creating-the-blur-area-object">Creating the Blur Area Object</h3>
<p>Every blur region is represented as a JavaScript object.</p>
<p>For example:</p>
<pre><code class="language-javascript">const blurArea = {

    page: currentPage,

    x: 0,

    y: 0,

    width: 0,

    height: 0,

    intensity: 6

};
</code></pre>
<p>Rather than modifying the PDF immediately, the application simply keeps track of these objects until the user clicks <strong>Apply &amp; Finalize</strong>.</p>
<h3 id="heading-storing-multiple-blur-regions">Storing Multiple Blur Regions</h3>
<p>Users often need to hide more than one piece of information.</p>
<p>Store all blur areas inside an array.</p>
<pre><code class="language-javascript">const blurAreas = [];
</code></pre>
<p>Whenever a new blur box is created, push it into the array.</p>
<pre><code class="language-javascript">blurAreas.push({

    page: currentPage,

    x,

    y,

    width,

    height,

    intensity:
        blurIntensity

});
</code></pre>
<p>This makes it easy to redraw, edit, or remove individual blur regions later.</p>
<h3 id="heading-starting-a-blur-selection">Starting a Blur Selection</h3>
<p>The transparent overlay canvas captures mouse interactions.</p>
<p>When the user presses the mouse button, record the starting position.</p>
<pre><code class="language-javascript">let isDrawing = false;

let startX = 0;

let startY = 0;

overlayCanvas
.addEventListener(
    "mousedown",
    event =&gt; {

        isDrawing = true;

        startX = event.offsetX;

        startY = event.offsetY;

    }
);
</code></pre>
<p>The blur rectangle begins at this point.</p>
<h3 id="heading-drawing-the-blur-rectangle">Drawing the Blur Rectangle</h3>
<p>As the mouse moves, update the rectangle dimensions.</p>
<pre><code class="language-javascript">overlayCanvas
.addEventListener(
    "mousemove",
    event =&gt; {

        if (
            !isDrawing
        ) {

            return;

        }

        drawPreviewBox(

            startX,

            startY,

            event.offsetX,

            event.offsetY

        );

    }
);
</code></pre>
<p>The preview updates continuously while the user drags the mouse.</p>
<h3 id="heading-finishing-the-selection">Finishing the Selection</h3>
<p>When the mouse button is released, save the completed blur region.</p>
<pre><code class="language-javascript">overlayCanvas
.addEventListener(
    "mouseup",
    event =&gt; {

        isDrawing = false;

        blurAreas.push({

            page:
                currentPage,

            x:
                startX,

            y:
                startY,

            width:
                event.offsetX -
                startX,

            height:
                event.offsetY -
                startY,

            intensity:
                blurIntensity

        });

        redrawBlurAreas();

        updateBlurList();

    }
);
</code></pre>
<p>Each blur region becomes part of the current editing session.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/31655d91-dcb6-412c-8762-c6aedc732b81.png" alt="User dragging a blur rectangle over sensitive information in the PDF preview." style="display:block;margin:0 auto" width="565" height="863" loading="lazy">

<h3 id="heading-drawing-existing-blur-areas">Drawing Existing Blur Areas</h3>
<p>Whenever the page changes or a blur region is added, redraw every blur box.</p>
<pre><code class="language-javascript">function redrawBlurAreas() {

    overlayContext.clearRect(

        0,

        0,

        overlayCanvas.width,

        overlayCanvas.height

    );

    blurAreas

        .filter(

            area =&gt;

                area.page ===
                currentPage

        )

        .forEach(

            drawBlurArea

        );

}
</code></pre>
<p>This ensures that previously created blur regions remain visible while editing.</p>
<h3 id="heading-displaying-blur-boxes">Displaying Blur Boxes</h3>
<p>Render every stored region with a dashed outline.</p>
<pre><code class="language-javascript">function drawBlurArea(
    area
) {

    overlayContext
        .setLineDash([6, 4]);

    overlayContext
        .strokeStyle =
        "#4f6cff";

    overlayContext
        .strokeRect(

            area.x,

            area.y,

            area.width,

            area.height

        );

}
</code></pre>
<p>The outline acts as a guide and doesn't become part of the final PDF.</p>
<h3 id="heading-blur-options">Blur Options</h3>
<p>Users can choose how the blur should be applied.</p>
<p>The tool supports two modes:</p>
<ul>
<li><p>Blur selected areas</p>
</li>
<li><p>Blur entire page(s)</p>
</li>
</ul>
<p>The selected option controls the editing behavior.</p>
<pre><code class="language-javascript">const blurMode =
document.querySelector(

    'input[name="blurMode"]:checked'

).value;
</code></pre>
<p>If <strong>Blur selected areas</strong> is chosen, users draw blur rectangles manually.</p>
<p>If <strong>Blur entire page(s)</strong> is selected, the application skips manual selection and prepares to blur the entire page during final processing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/54004f00-c974-4d77-b385-fe3136acaf04.png" alt="Blur options showing choices for blurring selected regions or entire PDF pages." style="display:block;margin:0 auto" width="642" height="231" loading="lazy">

<h3 id="heading-adjusting-blur-intensity">Adjusting Blur Intensity</h3>
<p>Different documents require different levels of blur.</p>
<p>A slider lets users control the blur strength before applying the effect.</p>
<pre><code class="language-javascript">const blurSlider =
document.getElementById(
    "blurIntensity"
);

let blurIntensity = 6;

blurSlider
.addEventListener(
    "input",
    event =&gt; {

        blurIntensity =
        Number(
            event.target.value
        );

    }
);
</code></pre>
<p>The selected value is stored with every newly created blur region.</p>
<pre><code class="language-javascript">blurArea.intensity =
blurIntensity;
</code></pre>
<p>Higher values produce a stronger blur effect.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/097cba2c-a441-401c-9f1c-f8ce0b5a26a1.png" alt="Blur intensity slider allowing users to adjust the strength of the blur effect." style="display:block;margin:0 auto" width="861" height="131" loading="lazy">

<h2 id="heading-managing-multiple-blur-areas">Managing Multiple Blur Areas</h2>
<p>Many documents contain several pieces of confidential information.</p>
<p>Instead of limiting users to a single blur rectangle, the application displays every saved region.</p>
<p>For example:</p>
<pre><code class="language-text">Blur Area #1

Blur Area #2

Blur Area #3
</code></pre>
<p>Each entry includes a remove button.</p>
<pre><code class="language-javascript">function removeBlurArea(
    index
) {

    blurAreas.splice(
        index,
        1
    );

    redrawBlurAreas();

    updateBlurList();

}
</code></pre>
<p>This allows users to delete only the blur region they no longer need.</p>
<p>To remove all blur regions from the current page:</p>
<pre><code class="language-javascript">function clearCurrentPage() {

    const remaining =

    blurAreas.filter(

        area =&gt;

            area.page !==
            currentPage

    );

    blurAreas.length = 0;

    blurAreas.push(
        ...remaining
    );

    redrawBlurAreas();

}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ef44e87e-7c39-4e2b-8e67-7e354853501d.png" alt="Blur area manager displaying multiple blur regions with delete controls and a Clear All on This Page button." style="display:block;margin:0 auto" width="876" height="273" loading="lazy">

<h2 id="heading-applying-blur-to-pages">Applying Blur to Pages</h2>
<p>Users may want to blur only one page or several pages within a document.</p>
<p>The editor provides three options:</p>
<ul>
<li><p>Current page only</p>
</li>
<li><p>All pages</p>
</li>
<li><p>Specific pages</p>
</li>
</ul>
<pre><code class="language-javascript">const pageOption =
document.querySelector(

'input[name="pageOption"]:checked'

).value;
</code></pre>
<p>If the user selects <strong>Specific pages</strong>, they can enter values such as:</p>
<pre><code class="language-text">1, 3-5, 8
</code></pre>
<p>These values will later be converted into an array of page numbers before the final PDF is generated.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f3e1a72c-690d-4454-8b7a-50920817cd13.png" alt="Apply to Pages section with Current Page, All Pages, and Specific Pages options." style="display:block;margin:0 auto" width="566" height="292" loading="lazy">

<h3 id="heading-applying-the-blur-effect">Applying the Blur Effect</h3>
<p>So far, users have uploaded a PDF, selected one or more blur regions, adjusted the blur intensity, and chosen which pages should be processed.</p>
<p>The final step is converting those blur regions into actual blurred content inside the generated PDF.</p>
<p>Rather than modifying the original document directly, the application creates a new PDF while preserving the original file.</p>
<h3 id="heading-loading-the-original-pdf">Loading the Original PDF</h3>
<p>Start by loading the uploaded PDF into PDF-lib.</p>
<pre><code class="language-javascript">async function applyBlur() {

    const pdfDoc =

        await PDFLib.PDFDocument.load(
            originalPdfBytes.slice()
        );

    const pages =
        pdfDoc.getPages();

}
</code></pre>
<p>Using a copy of the original bytes ensures that the uploaded document remains unchanged.</p>
<h3 id="heading-processing-the-selected-pages">Processing the Selected Pages</h3>
<p>Determine which pages should receive the blur effect.</p>
<pre><code class="language-javascript">const selectedPages =

parsePageSelection(

    pageSelection,

    pdfDoc.getPageCount()

);
</code></pre>
<p>For example:</p>
<pre><code class="language-text">Current Page

↓

[2]


All Pages

↓

[1,2,3,4]


Specific Pages

↓

[1,3,5]
</code></pre>
<p>Only these pages will be modified during processing.</p>
<h3 id="heading-rendering-each-page-as-an-image">Rendering Each Page as an Image</h3>
<p>Since blur is a pixel-based effect, each selected PDF page is rendered into an off-screen canvas.</p>
<pre><code class="language-javascript">const page =

await pdfDocument.getPage(
    pageNumber
);

const viewport =
page.getViewport({

    scale: 2

});

const canvas =
document.createElement(
    "canvas"
);

canvas.width =
viewport.width;

canvas.height =
viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({

    canvasContext:
    canvas.getContext("2d"),

    viewport

}).promise;
</code></pre>
<p>The canvas now contains a bitmap version of the PDF page that can be edited.</p>
<h3 id="heading-applying-blur-to-selected-regions">Applying Blur to Selected Regions</h3>
<p>Retrieve all blur regions that belong to the current page.</p>
<pre><code class="language-javascript">const pageRegions =

blurAreas.filter(

    area =&gt;

        area.page === pageNumber

);
</code></pre>
<p>Loop through every blur region.</p>
<pre><code class="language-javascript">pageRegions.forEach(

    area =&gt; {

        blurCanvasRegion(

            canvas,

            area

        );

    }

);
</code></pre>
<p>Each region is blurred independently.</p>
<h3 id="heading-blurring-the-canvas-region">Blurring the Canvas Region</h3>
<p>The browser's Canvas API allows filters to be applied while drawing.</p>
<p>Set the blur filter based on the selected intensity.</p>
<pre><code class="language-javascript">context.filter =

`blur(${area.intensity}px)`;
</code></pre>
<p>Redraw only the selected region.</p>
<pre><code class="language-javascript">context.drawImage(

    canvas,

    area.x,

    area.y,

    area.width,

    area.height,

    area.x,

    area.y,

    area.width,

    area.height

);
</code></pre>
<p>After drawing, reset the filter.</p>
<pre><code class="language-javascript">context.filter = "none";
</code></pre>
<p>Only the selected rectangle becomes blurred while the rest of the page remains unchanged.</p>
<h3 id="heading-blurring-an-entire-page">Blurring an Entire Page</h3>
<p>If the user chooses <strong>Blur entire page(s)</strong>, the process is much simpler.</p>
<p>Apply the filter to the full canvas.</p>
<pre><code class="language-javascript">context.filter =

`blur(${blurIntensity}px)`;

context.drawImage(

    canvas,

    0,

    0

);

context.filter =
"none";
</code></pre>
<p>The entire rendered page receives the selected blur effect.</p>
<h3 id="heading-converting-the-canvas-back-into-a-pdf-image">Converting the Canvas Back into a PDF Image</h3>
<p>After editing the canvas, convert it into an image.</p>
<pre><code class="language-javascript">const imageData =

canvas.toDataURL(
    "image/png"
);
</code></pre>
<p>Convert the image into bytes.</p>
<pre><code class="language-javascript">const bytes =

await fetch(imageData)

.then(

response =&gt;

response.arrayBuffer()

);
</code></pre>
<p>Embed the image inside PDF-lib.</p>
<pre><code class="language-javascript">const image =

await pdfDoc.embedPng(
    bytes
);
</code></pre>
<p>Replace the page contents.</p>
<pre><code class="language-javascript">const pdfPage =

pages[
pageNumber - 1
];

const size =
pdfPage.getSize();

pdfPage.drawImage(

    image,

    {

        x: 0,

        y: 0,

        width:
        size.width,

        height:
        size.height

    }

);
</code></pre>
<p>Repeat the same process for every selected page.</p>
<h3 id="heading-showing-the-processing-state">Showing the Processing State</h3>
<p>Generating large PDF files may take a few seconds.</p>
<p>Display a loading state while processing.</p>
<pre><code class="language-javascript">applyButton.disabled =
true;

applyButton.textContent =
"Applying...";
</code></pre>
<p>After processing finishes:</p>
<pre><code class="language-javascript">applyButton.disabled =
false;

applyButton.textContent =
"Apply &amp; Finalize";
</code></pre>
<p>This gives users clear feedback that the application is working.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/75e1e622-9769-4c17-8f39-49376de81041.png" alt="Apply &amp; Finalize button used to generate the blurred PDF." style="display:block;margin:0 auto" width="582" height="92" loading="lazy">

<p>During processing:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/68ae927f-aa3f-4e8c-8b37-2ef89ba0ad40.png" alt="Applying state displayed while the PDF blur operation is being completed." style="display:block;margin:0 auto" width="285" height="113" loading="lazy">

<h2 id="heading-generating-the-final-pdf">Generating the Final PDF</h2>
<p>After every page has been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes =

await pdfDoc.save();

const outputBlob =
new Blob(

[pdfBytes],

{

type:
"application/pdf"

}

);
</code></pre>
<p>Store the result so it can be previewed and downloaded later.</p>
<pre><code class="language-javascript">generatedPdfBlob =
outputBlob;
</code></pre>
<p>At this point, the blurred PDF has been successfully generated.</p>
<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Hide the editing interface and display the completed document.</p>
<pre><code class="language-javascript">editorSection.hidden =
true;

resultSection.hidden =
false;
</code></pre>
<p>The result screen displays:</p>
<ul>
<li><p>Final PDF preview</p>
</li>
<li><p>Editable filename</p>
</li>
<li><p>Total pages</p>
</li>
<li><p>File size</p>
</li>
<li><p>Download button</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1e96e1ec-b114-4ebb-955e-1e277d7f98da.png" alt="Final PDF preview showing multiple blurred regions with download options displayed beside the document." style="display:block;margin:0 auto" width="857" height="816" loading="lazy">

<p>The user can review the processed document before downloading it.</p>
<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before downloading, users may want to rename the generated file.</p>
<p>Create a filename field.</p>
<pre><code class="language-html">&lt;input

type="text"

id="outputFilename"

value="blurred-document.pdf"&gt;
</code></pre>
<p>Validate the filename.</p>
<pre><code class="language-javascript">function getFilename() {

    let filename =
    outputFilename.value.trim();

    if (!filename) {

        filename =
        "blurred-document.pdf";

    }

    if (
        !filename
        .toLowerCase()
        .endsWith(".pdf")
    ) {

        filename += ".pdf";

    }

    return filename;

}
</code></pre>
<p>Display additional file information.</p>
<pre><code class="language-javascript">pageCount.textContent =

`Pages:
${finalPdfDocument.numPages}`;

fileSize.textContent =

formatFileSize(
generatedPdfBlob.size
);
</code></pre>
<p>Download the processed document.</p>
<pre><code class="language-javascript">downloadButton
.addEventListener(

"click",

() =&gt; {

    const url =

    URL.createObjectURL(
        generatedPdfBlob
    );

    const link =
    document.createElement(
        "a"
    );

    link.href = url;

    link.download =
    getFilename();

    link.click();

    URL.revokeObjectURL(
        url
    );

});
</code></pre>
<p>The browser downloads the completed PDF without sending any files to a remote server.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1cd1538e-7a90-4994-bbd0-da84365e1d34.png" alt="Download section showing editable filename, page count, file size, and Download button." style="display:block;margin:0 auto" width="272" height="166" loading="lazy">

<p><img src="align=%22center%22" alt="align=%22center%22" width="600" height="400" loading="lazy"></p>
<h2 id="heading-demo-how-the-pdf-blur-tool-works">Demo: How the PDF Blur Tool Works</h2>
<p>Let's walk through the complete workflow.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users upload a PDF using drag-and-drop or the <strong>Select PDF</strong> button.</p>
<p>The browser validates the file and prepares it for rendering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9a08c61c-814a-4c44-9a74-6f4eef7ddefc.png" alt="Upload screen for selecting a PDF file." style="display:block;margin:0 auto" width="1256" height="515" loading="lazy">

<h3 id="heading-step-2-preview-the-document">Step 2: Preview the Document</h3>
<p>The uploaded PDF appears inside the preview window.</p>
<p>Users can move through the document using the page navigation controls.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/27024335-d786-476c-9c34-e050ca4162e7.png" alt="PDF preview with Previous and Next page navigation." style="display:block;margin:0 auto" width="546" height="818" loading="lazy">

<h3 id="heading-step-3-configure-blur-settings">Step 3: Configure Blur Settings</h3>
<p>Users choose whether to blur selected areas or entire pages.</p>
<p>They can also configure the blur intensity before creating any blur regions.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6fdcce22-b42d-48ca-94cd-c99bc679b7f5.png" alt="Blur settings panel showing available blur options." style="display:block;margin:0 auto" width="400" height="758" loading="lazy">

<h3 id="heading-step-4-draw-blur-areas">Step 4: Draw Blur Areas</h3>
<p>Users click and drag directly on the PDF preview to create blur rectangles over sensitive content.</p>
<p>Multiple blur regions can be created on the same page.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c5404708-118c-4f7f-a768-c20ea66626a7.png" alt="User creating blur rectangles over confidential information." style="display:block;margin:0 auto" width="565" height="863" loading="lazy">

<h3 id="heading-step-5-adjust-blur-intensity">Step 5: Adjust Blur Intensity</h3>
<p>The blur intensity slider controls how strong the blur effect should appear.</p>
<p>Higher values produce a stronger blur.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/b485fa69-8952-4e4a-b91c-945c9879838a.png" alt="blur seleted option" style="display:block;margin:0 auto" width="642" height="231" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d0ece3b0-2c75-45a2-9286-8a38a0ef706a.png" alt="Blur intensity slider controlling the strength of the blur effect." style="display:block;margin:0 auto" width="861" height="131" loading="lazy">

<h3 id="heading-step-6-manage-blur-regions">Step 6: Manage Blur Regions</h3>
<p>Individual blur areas can be removed, or all blur regions on the current page can be cleared.</p>
<p>This makes editing much easier before generating the final PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c90a4b21-9cfe-4c99-9b11-29b862621ca1.png" alt="Blur area management panel with multiple blur regions." style="display:block;margin:0 auto" width="876" height="273" loading="lazy">

<h3 id="heading-step-7-choose-the-pages">Step 7: Choose the Pages</h3>
<p>Users decide whether the blur should be applied to:</p>
<ul>
<li><p>Current page</p>
</li>
<li><p>All pages</p>
</li>
<li><p>Specific pages</p>
</li>
</ul>
<p>For example:</p>
<pre><code class="language-text">1,3-5,8
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/663facb2-f312-43a0-b4c8-4a029da0ed81.png" alt="Apply to Pages section with Current Page, All Pages, and Specific Pages options." style="display:block;margin:0 auto" width="566" height="292" loading="lazy">

<h3 id="heading-step-8-apply-the-blur">Step 8: Apply the Blur</h3>
<p>After reviewing the settings, users click <strong>Apply &amp; Finalize</strong>.</p>
<p>The application generates a new PDF containing the selected blur effects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/53eb6910-25ec-4b66-aaf1-a0c11363a3f8.png" alt="Apply &amp; Finalize button generating the blurred PDF." style="display:block;margin:0 auto" width="582" height="92" loading="lazy">

<h3 id="heading-step-9-review-the-final-document">Step 9: Review the Final Document</h3>
<p>The completed PDF appears in the preview window.</p>
<p>Users can verify every blurred region before downloading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/834a4f6d-e12a-45ae-b977-e761f624bb34.png" alt=" Final blurred PDF preview before downloading." style="display:block;margin:0 auto" width="857" height="816" loading="lazy">

<h3 id="heading-step-10-rename-and-download">Step 10: Rename and Download</h3>
<p>Finally, users rename the output file if needed and click <strong>Download</strong>.</p>
<p>The browser saves the completed PDF locally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/573a6966-d0af-47af-a4d3-2c99b9a79622.png" alt=" Download section showing filename editing and Download button." style="display:block;margin:0 auto" width="272" height="166" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Large PDF files can take longer to render and process, but a few simple optimizations can keep the editor responsive.</p>
<p>Render only the page the user is currently viewing instead of loading the entire document.</p>
<pre><code class="language-javascript">await renderPage(
    currentPage
);
</code></pre>
<p>Reuse the same canvas and redraw only the blur regions when changes are made.</p>
<pre><code class="language-javascript">overlayContext.clearRect(
    0,
    0,
    overlayCanvas.width,
    overlayCanvas.height
);

redrawBlurAreas();
</code></pre>
<p>During final processing, generate only the pages selected by the user.</p>
<pre><code class="language-javascript">for (const page of selectedPages) {

    await processPage(page);

}
</code></pre>
<p>Finally, release temporary resources after the download completes.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(
    downloadUrl
);
</code></pre>
<p>These optimizations reduce memory usage and help the PDF Blur Tool perform smoothly, even with large multi-page documents.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>One common issue is storing blur coordinates before accounting for the current zoom level.</p>
<p>Always convert preview coordinates into the PDF's coordinate system before generating the final document.</p>
<pre><code class="language-javascript">const scaleX =

pdfWidth /
canvas.width;

const scaleY =

pdfHeight /
canvas.height;
</code></pre>
<p>Another mistake is allowing blur regions to extend beyond the page boundaries.</p>
<p>Clamp the values before processing.</p>
<pre><code class="language-javascript">blurArea.x = Math.max(
0,
blurArea.x
);

blurArea.y = Math.max(
0,
blurArea.y
);
</code></pre>
<p>Users should also verify the final preview before downloading, especially when multiple blur regions exist across different pages.</p>
<p>Finally, remember that this project applies a <strong>visual blur effect</strong> to the rendered PDF pages. If your application requires permanent removal of sensitive content rather than visual obscuring, additional document-redaction techniques are needed.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF Blur Tool using JavaScript.</p>
<p>You learned how to upload and preview PDF documents, navigate between pages, create and manage multiple blur regions, adjust blur intensity, apply blur to selected pages, generate a new PDF with PDF-lib, preview the processed document, and download the final file –&nbsp;all without uploading data to a server.</p>
<p>By combining PDF.js, the HTML Canvas API, and PDF-lib, you created a privacy-focused PDF editing tool that runs entirely inside the browser.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/blur-pdf/">PDF Blur Tool</a>.</p>
<p>From here, you could extend the project with features such as movable and resizable blur regions, undo and redo support, reusable blur presets, keyboard shortcuts, touch-device editing, or additional annotation tools for even more advanced browser-based PDF editing.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Run an AI Extractability Audit on Your Site (I Found 6 Heading Tags That Cost Me Citations) ]]>
                </title>
                <description>
                    <![CDATA[ When an AI assistant answers a question, it lifts sentences from a handful of pages and cites them. Whether your page is liftable is not a mystery or a vibe. It's a set of mechanical properties of you ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-run-an-ai-extractability-audit/</link>
                <guid isPermaLink="false">6a614f37a80e58ea2984c135</guid>
                
                    <category>
                        <![CDATA[ SEO ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ web scraping ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chudi Nnorukam ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 23:16:07 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9c86b8bb-fdda-4f95-9175-623de49c584c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When an AI assistant answers a question, it lifts sentences from a handful of pages and cites them. Whether your page is liftable is not a mystery or a vibe. It's a set of mechanical properties of your HTML that you can measure, score, and fix.</p>
<p>This tutorial walks through the exact audit I ran on my own site, the six invisible heading tags it caught, the one-commit fix, and the CI gate that keeps the problem from coming back.</p>
<p>Here is the punchline up front: my homepage scored 65 out of 100 on extractability. The cause was five UI card components that rendered their titles as <code>&lt;h2&gt;</code> and <code>&lt;h3&gt;</code> tags. Demoting those six headings to ARIA-preserving paragraphs, without changing a single visible pixel or removing one word of content, took the page to 100.</p>
<p>Over the last 90 days, Microsoft's Bing Webmaster Tools reports 1,600 AI citations across 33 of my pages. Extraction is the stage of that pipeline this tutorial teaches you to audit.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-an-extractability-audit-actually-tests">What an Extractability Audit Actually Tests</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-pick-the-pages-worth-auditing">Step 1: Pick the Pages Worth Auditing</a></p>
</li>
<li><p><a href="#heading-step-2-run-the-five-checks">Step 2: Run the Five Checks</a></p>
</li>
<li><p><a href="#heading-step-3-read-your-failure-classes">Step 3: Read Your Failure Classes</a></p>
</li>
<li><p><a href="#heading-step-4-find-the-components-emitting-fake-headings">Step 4: Find the Components Emitting Fake Headings</a></p>
</li>
<li><p><a href="#heading-step-5-demote-the-headings-without-breaking-accessibility">Step 5: Demote the Headings Without Breaking Accessibility</a></p>
</li>
<li><p><a href="#heading-step-6-gate-the-fix-in-ci">Step 6: Gate the Fix in CI</a></p>
</li>
<li><p><a href="#heading-what-actually-moved">What Actually Moved</a></p>
</li>
<li><p><a href="#heading-what-i-rejected-and-why">What I Rejected, and Why</a></p>
</li>
<li><p><a href="#heading-faq">FAQ</a></p>
</li>
<li><p><a href="#heading-what-you-accomplished">What You Accomplished</a></p>
</li>
</ul>
<h2 id="heading-what-an-extractability-audit-actually-tests">What an Extractability Audit Actually Tests</h2>
<p>A citation from an AI engine is the last step of a three-stage machine pipeline, and your page has to pass every stage:</p>
<ol>
<li><p><strong>Retrieve</strong>: the engine's crawler is allowed to fetch your page, and does.</p>
</li>
<li><p><strong>Extract</strong>: the model finds a clean, self-contained answer in your markup.</p>
</li>
<li><p><strong>Attribute</strong>: the engine is confident enough about who said it to put your name next to it.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/793015f2-359e-497a-b18b-4238999aa83e.png" alt="Three-stage pipeline diagram labeled Retrieve, Extract, Attribute, showing that an AI engine must fetch a page, lift a clean answer from its markup, and identify the author before a citation appears." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>Most AI-visibility advice concentrates on stage 1 (robots.txt, sitemaps, llms.txt) and stage 3 (schema, entity signals). Stage 2 is where I've found the cheapest wins, because it's pure HTML engineering, and because it fails silently: a page that retrieves fine and attributes fine but extracts poorly simply never appears in answers, and nothing tells you why.</p>
<p><strong>Extractability</strong> is the measurable version of stage 2: can a parser walking your rendered HTML find self-contained answer blocks under clearly scoped headings? The audit in this tutorial scores that on a 0 to 100 scale using five checks, each of which you can verify by hand:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>What it tests</th>
<th>Weight</th>
</tr>
</thead>
<tbody><tr>
<td>F1</td>
<td>The first sentence under every H2 stands alone as an answer</td>
<td>30</td>
</tr>
<tr>
<td>F2</td>
<td>The first 200 tokens of the page contain a direct answer</td>
<td>20</td>
</tr>
<tr>
<td>F3</td>
<td>Each H2 section opens with an answer in the 40 to 60 word band</td>
<td>20</td>
</tr>
<tr>
<td>F4</td>
<td>Share of H2/H3 headings phrased as questions a user would type</td>
<td>20</td>
</tr>
<tr>
<td>F5</td>
<td>An FAQ section exists at the article footer</td>
<td>10</td>
</tr>
</tbody></table>
<p>A score of 75 or above lands in the EXTRACTABLE band. 40 to 74 is PARTIALLY-EXTRACTABLE. Below 40 is NOT-EXTRACTABLE. The bands come from the AI Visibility Readiness framework I maintain, but the five checks themselves are engine-agnostic: they encode how retrieval-augmented systems chunk pages by heading, embed the chunks, and lift the opening sentences of whichever chunk matches the query.</p>
<p>The critical detail for this tutorial: <strong>the audit counts every</strong> <code>&lt;h1&gt;</code><strong>,</strong> <code>&lt;h2&gt;</code><strong>, and</strong> <code>&lt;h3&gt;</code> <strong>in your rendered DOM.</strong> Not the headings you wrote in your CMS. The headings your component library emits. That gap is where my six invisible failures lived.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A live website you can measure and deploy (Any stack. My examples are SvelteKit, and every fix translates to React, Vue, or plain HTML.)</p>
</li>
<li><p>Python 3.10+ with <code>requests</code> and <code>beautifulsoup4</code> (<code>pip install requests beautifulsoup4</code>)</p>
</li>
<li><p>Access to your search console data (Google Search Console or Bing Webmaster Tools) to pick pages</p>
</li>
<li><p>A CI system (the example uses GitHub Actions)</p>
</li>
<li><p>About 90 minutes: 20 for the audit, 40 for the fix, 30 for the CI gate</p>
</li>
</ul>
<h2 id="heading-step-1-pick-the-pages-worth-auditing">Step 1: Pick the Pages Worth Auditing</h2>
<p>Don't audit your whole sitemap. Audit the pages that already have distribution, because extraction fixes multiply whatever retrieval you already earn.</p>
<p>Open Google Search Console, go to Performance, sort pages by impressions over the last 28 days, and look at where your distribution actually lives.</p>
<p>Here's the top of my own report from that export (July 21):</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Impressions (28d)</th>
<th>Clicks</th>
<th>Avg position</th>
</tr>
</thead>
<tbody><tr>
<td>/blog/claude-fable-5-vs-opus-4-8</td>
<td>17,315</td>
<td>462</td>
<td>6.2</td>
</tr>
<tr>
<td>/blog/how-i-built-polymarket-trading-bot</td>
<td>13,649</td>
<td>104</td>
<td>7.6</td>
</tr>
<tr>
<td>/blog/claude-code-production-trading-bot</td>
<td>6,540</td>
<td>94</td>
<td>8.5</td>
</tr>
<tr>
<td>/blog/aeo-answer-engine-optimization-explained</td>
<td>4,189</td>
<td>1</td>
<td>8.2</td>
</tr>
</tbody></table>
<p>Individual posts dominate the impressions, but notice what every one of those posts has in common: they're all rendered by the same layout and card components.</p>
<p>Fixing a component fixes every page that uses it at once, which is why I scoped the audit to the top 3 to 5 <strong>content-index pages</strong> instead of individual posts: the homepage, your blog index, your topic or category hubs.</p>
<p>Index pages are assembled almost entirely from repeating cards, so they show component damage in its most concentrated form, and any fix propagates to everything else.</p>
<p>I chose these three:</p>
<ul>
<li><p><code>chudi.dev/</code> (the homepage)</p>
</li>
<li><p><code>chudi.dev/blog</code> (the writing index)</p>
</li>
<li><p><code>chudi.dev/topics</code> (the topic hub)</p>
</li>
</ul>
<p><strong>Artifact check:</strong> you should now have a written list of 3 to 5 URLs. That list is the audit's scope.</p>
<h2 id="heading-step-2-run-the-five-checks">Step 2: Run the Five Checks</h2>
<p>You can score the five checks with about 60 lines of Python. This is a deliberately minimal version of the auditor I run in production. It implements the two checks that catch component damage (F3 and F4) plus a full heading census, which is enough to find the class of bug this tutorial fixes.</p>
<pre><code class="language-python">import re
import sys
import requests
from bs4 import BeautifulSoup

QUESTION = re.compile(
    r"^\s*(what|how|why|when|where|who|which|is|are|can|do|does|should|will|did)\b|\?\s*$",
    re.IGNORECASE,
)

def audit(url):
    html = requests.get(url, timeout=8, headers={"User-Agent": "extract-audit/1.0"}).text
    soup = BeautifulSoup(html, "html.parser")

    headings = [(h.name, " ".join(h.get_text().split())) for h in soup.find_all(["h1", "h2", "h3"])]
    subheads = [(n, t) for n, t in headings if n in ("h2", "h3")]

    question_rate = (
        sum(1 for _, t in subheads if QUESTION.search(t)) / len(subheads) if subheads else 0.0
    )

    in_band = 0
    h2s = soup.find_all("h2")
    for h2 in h2s:
        first_p = h2.find_next("p")
        words = len(first_p.get_text().split()) if first_p else 0
        if 40 &lt;= words &lt;= 60:
            in_band += 1

    print(f"URL: {url}")
    print(f"Heading census ({len(headings)} total):")
    for name, text in headings:
        print(f"  &lt;{name}&gt; {text[:70]}")
    print(f"F4 question-format rate: {question_rate:.1%} (target &gt;= 50%)")
    print(f"F3 sections opening in the 40-60 word band: {in_band}/{len(h2s)}")

if __name__ == "__main__":
    audit(sys.argv[1])
</code></pre>
<p>Run it against each page on your list:</p>
<pre><code class="language-bash">python3 extract_audit.py https://yoursite.com/
</code></pre>
<p>The heading census is the part to stare at. It prints every H1/H2/H3 a parser sees, in order, which is frequently not the outline you think you published.</p>
<p>If you want the full five-check scored version with the weighted 0 to 100 composite, the <a href="https://citability.dev">automated audit on citability.dev</a> runs all five checks plus retrieval and attribution layers. The manual version above is enough to complete this tutorial.</p>
<p><strong>Artifact check:</strong> a terminal output per page showing the heading census, the F4 rate, and the F3 band count. Screenshot it. It is your before-state.</p>
<h2 id="heading-step-3-read-your-failure-classes">Step 3: Read Your Failure Classes</h2>
<p>Here's what the audit said about my homepage before the fix, pulled from the commit record of the remediation (2026-05-23):</p>
<ul>
<li><p>Score: <strong>65/100, PARTIALLY-EXTRACTABLE</strong>, ten points under the threshold</p>
</li>
<li><p>F4 question-format rate: <strong>26.7%</strong>, far below the 50% pass line</p>
</li>
<li><p>Cause: more than ten headings in the census that I never wrote as headings</p>
</li>
</ul>
<p>The census made the cause obvious. Alongside the section headings I had deliberately tuned ("How do I see it run live?", "What is the retrieval header?") sat a pile of statements like blog post titles and project names, each wrapped in <code>&lt;h2&gt;</code> or <code>&lt;h3&gt;</code>. I hadn't typed a single one of them into a heading field. My card components had.</p>
<p>This is the general lesson, and it is worth stating as a rule:</p>
<p><strong>The denominator is the design problem.</strong> Every heading your components emit joins the denominator of every ratio check an extraction parser runs. Ten card titles as H3s means your carefully tuned question headings are outvoted 10 to 4 by markup you never see.</p>
<p>Failure classes map to fixes like this:</p>
<table>
<thead>
<tr>
<th>Symptom in the census</th>
<th>Failure class</th>
<th>Fix (Step)</th>
</tr>
</thead>
<tbody><tr>
<td>Headings you never wrote, repeated in card-sized clusters</td>
<td>Component-emitted headings</td>
<td>Steps 4 and 5</td>
</tr>
<tr>
<td>Your own H2s are statements, not questions</td>
<td>Authored heading style</td>
<td>Rephrase to question form</td>
</tr>
<tr>
<td>Sections open with a 15-word teaser or a 120-word ramble</td>
<td>Answer-band miss</td>
<td>Densify openers to 40 to 60 words</td>
</tr>
<tr>
<td>No FAQ block</td>
<td>Missing F5 surface</td>
<td>Add one at the footer</td>
</tr>
</tbody></table>
<p>I had all four classes across my three pages. The component class was the biggest single scorer, and it's the one nobody catches by reading their CMS, so it gets the deep treatment here. (For the record, the authored fixes on my other pages were exactly what the table says: two H2s on my framework page rephrased into question form, and a topic-hub opener expanded from 37 words to roughly 50 to enter the answer band.)</p>
<p><strong>Artifact check:</strong> your census annotated with the four failure classes. Count how many headings you didn't author.</p>
<h2 id="heading-step-4-find-the-components-emitting-fake-headings">Step 4: Find the Components Emitting Fake Headings</h2>
<p>The census tells you fake headings exist. Your component library tells you where they come from. Grep for heading tags inside your component directory, not your content:</p>
<pre><code class="language-bash">grep -rn "&lt;h[23]" src/lib/components/ --include="*.svelte"
</code></pre>
<p>(React: <code>grep -rn "&lt;h[23]" src/components/ --include="*.tsx"</code>. Vue: same idea with <code>.vue</code>.)</p>
<p>On my site, this surfaced six heading sites across five components:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Emitted</th>
<th>Instances</th>
</tr>
</thead>
<tbody><tr>
<td><code>BlogCard.svelte</code></td>
<td><code>&lt;h3&gt;</code> post title</td>
<td>2</td>
</tr>
<tr>
<td><code>BlogCardFeatured.svelte</code></td>
<td><code>&lt;h2&gt;</code> post title</td>
<td>1</td>
</tr>
<tr>
<td><code>ProductCard.svelte</code></td>
<td><code>&lt;h3&gt;</code> product name</td>
<td>1</td>
</tr>
<tr>
<td><code>ProjectCard.svelte</code></td>
<td><code>&lt;h2&gt;</code> project name</td>
<td>1</td>
</tr>
<tr>
<td><code>JourneyCard.svelte</code></td>
<td><code>&lt;h2&gt;</code> milestone title</td>
<td>1</td>
</tr>
</tbody></table>
<p>Six tags doesn't sound like much until you remember that cards repeat. One blog index rendering ten <code>BlogCard</code> instances injects ten <code>&lt;h3&gt;</code> statements into that page's census. Every card-built page on the site inherits the same dilution, which is exactly why my content-index pages scored worst.</p>
<p>Why do component libraries do this? Because a card title <em>looks</em> like a heading, and because accessibility guidance rightly encourages semantic HTML.</p>
<p>The mistake is subtler: a card title is a <strong>link label into another document</strong>, not a section heading of <strong>this</strong> document. The page's real outline is "here are my featured posts", not the title of each post teased below it. HTML has no tag for "title of a different page", so components default to H2/H3, and every parser that walks the page inherits a false outline.</p>
<p><strong>Artifact check:</strong> a table like the one above: component, tag emitted, instance count. This is your fix list.</p>
<h2 id="heading-step-5-demote-the-headings-without-breaking-accessibility">Step 5: Demote the Headings Without Breaking Accessibility</h2>
<p>The obvious fix, swapping <code>&lt;h3&gt;</code> for a styled <code>&lt;span&gt;</code> or <code>&lt;p&gt;</code>, has a real cost: screen reader users navigate by heading structure, and card titles are genuinely useful landmarks when scanning a list of posts. Deleting the semantics entirely trades an AI-extraction win for an accessibility loss. That trade isn't necessary.</p>
<p>The fix that preserves both is <strong>ARIA heading demotion</strong>: replace the literal tag with a paragraph carrying <code>role="heading"</code> and an explicit <code>aria-level</code>.</p>
<p>One important clarification before the diff: the first rule of ARIA is to prefer native HTML elements, and this fix doesn't violate it. The rule applies when the text genuinely is a heading of the current document, and the whole point of Step 4 was establishing that card titles are not. They are link labels into other documents.</p>
<p>Native <code>&lt;h3&gt;</code> was the wrong semantics, while the ARIA role is a courtesy that keeps the list-scanning navigation screen reader users already rely on.</p>
<p>Here's the actual diff from my <code>BlogCard.svelte</code>, unchanged except for wrapping:</p>
<pre><code class="language-diff">-&lt;h3 class="text-[20px] md:text-[22px] font-bold leading-snug
+&lt;p role="heading" aria-level="3" class="text-[20px] md:text-[22px] font-bold leading-snug
   text-[var(--color-text-primary)]
   group-hover:text-[var(--color-primary)]
   transition-colors line-clamp-2"&gt;
   {post.title}
-&lt;/h3&gt;
+&lt;/p&gt;
</code></pre>
<p>What changes and what does not:</p>
<ul>
<li><p><strong>Assistive technology sees the same outline.</strong> <code>role="heading"</code> plus <code>aria-level="3"</code> is the ARIA-standard equivalent of an <code>&lt;h3&gt;</code>. Screen readers that navigate by heading still stop here and still announce the level.</p>
</li>
<li><p><strong>Visual styling is untouched.</strong> Every class stays on the element. Zero pixels move.</p>
</li>
<li><p><strong>Content is untouched.</strong> The fix removes zero words. This matters because most extraction advice tells you to rewrite. But this class of bug needs no rewriting.</p>
</li>
<li><p><strong>HTML-tag parsers stop counting it.</strong> Extraction pipelines chunk by literal <code>h1</code>/<code>h2</code>/<code>h3</code> elements. The card title exits the census, your authored headings get the denominator back, and the ratios you tuned start passing.</p>
</li>
</ul>
<p>Apply the same one-line change at every site on your Step 4 fix list. Mine was one commit touching five components, six occurrences.</p>
<p>Then redeploy and re-run the Step 2 audit. My homepage went from 65 to <strong>100/100 EXTRACTABLE</strong> on the post-deploy re-score, with the question-format rate recovering from 26.7% to above the 50% threshold, because the four question headings I had authored were finally the only H2/H3 population on the page.</p>
<p><strong>Artifact check:</strong> the after-audit terminal output next to your before screenshot. The heading census should now contain only headings you wrote on purpose.</p>
<h2 id="heading-step-6-gate-the-fix-in-ci">Step 6: Gate the Fix in CI</h2>
<p>Here's the uncomfortable truth about extraction scores: they drift. Content changes, components get added, or a redesign ships a new card.</p>
<p>My homepage, re-audited live while writing this tutorial (July 21), sits at 80: still EXTRACTABLE, but down from its post-fix 100, because a homepage redesign in the intervening weeks changed the section structure again. The blog index and topic hub both still score 100.</p>
<p>That drift is why the durable deliverable of this tutorial isn't the fix. It's the regression gate. Without one, the next well-meaning component ships a new <code>&lt;h2&gt;</code> and your score quietly decays. Nothing visible breaks, so nothing gets caught in review.</p>
<p>Mine runs as a GitHub Actions workflow triggered by every successful production deployment, and hard-fails if any audited URL drops out of the EXTRACTABLE band:</p>
<pre><code class="language-yaml">name: Post-Deploy Extractability Audit

on:
  deployment_status:

jobs:
  audit:
    if: |
      github.event.deployment_status.state == 'success' &amp;&amp;
      github.event.deployment.environment == 'Production'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install requests beautifulsoup4
      - name: Audit extractability on the live URLs
        run: |
          for url in "https://yoursite.com/" "https://yoursite.com/blog"; do
            python3 scripts/extract_audit.py "$url" --min-score 75 || exit 1
          done
</code></pre>
<p>To make the minimal auditor CI-ready, add a <code>--min-score</code> flag that exits nonzero below the threshold. That's a five-line change to the Step 2 script (compute the weighted score from the checks you implement, compare, <code>sys.exit(1)</code>).</p>
<p>The production version of my gate audits five URLs and stacks Lighthouse accessibility thresholds into the same workflow, so the ARIA-demotion contract from Step 5 is enforced from both directions: extraction can't regress below 75, and accessibility can't regress below 95. That pairing is the whole point. The two constraints keep each other honest.</p>
<p><strong>Artifact check:</strong> a CI run in your Actions tab that fails when you feed it <code>--min-score 101</code> (proving it can fail) and passes at 75.</p>
<h2 id="heading-what-actually-moved">What Actually Moved</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/f1d4463a-5517-4642-b69a-5401ae46e68d.png" alt="Bar chart of the chudi.dev homepage extractability score at three points: 65 before the May 2026 heading fix, 100 on the post-deploy re-score, and 80 on the July 21 live re-audit. A dashed line marks the extractable threshold at 75." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>The scoreboard for my three pages, all numbers from the same instrument:</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Before fix (May)</th>
<th>After fix</th>
<th>Live re-audit (July 21)</th>
</tr>
</thead>
<tbody><tr>
<td>Homepage</td>
<td>65 PARTIALLY-EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
<td>80 EXTRACTABLE</td>
</tr>
<tr>
<td>Blog index</td>
<td>below threshold</td>
<td>100 EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
</tr>
<tr>
<td>Topic hub</td>
<td>below threshold</td>
<td>100 EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/793015f2-359e-497a-b18b-4238999aa83e.png" alt="Bing Webmaster Tools AI Performance dashboard showing 1,600 total AI citations across 33 cited pages for chudi.dev over the 90 days ending July 19, 2026." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>And the downstream metric the audit exists to serve: Bing Webmaster Tools' AI Performance report (the only first-party AI citation dashboard that currently exists. You'll find it in your BWT property under Search Performance) shows my site earning <strong>1,600 AI citations across 33 pages in the 90 days ending July 19</strong>, from Microsoft Copilot and partner assistants. That number was 671 in late April, around when this remediation arc started, and roughly 1,500 by late June.</p>
<p>A note on causality, because this is where AI-visibility content usually oversells: the citation growth is correlated with the extraction work, not cleanly attributed to it. Over the same window, I also shipped content, fixed retrieval issues, and grew regular search traffic.</p>
<p>What I can defend: the audit scores are fully causal (the same instrument, before and after, moved because of one commit), the mechanism is documented engine behavior (heading-based chunking), and the citations kept compounding after the fix. What I can't give you is a controlled experiment isolating six heading tags. Nobody really can.</p>
<h2 id="heading-what-i-rejected-and-why">What I Rejected, and Why</h2>
<p>Selection bias is the failure mode of tutorials like this one, so here's what I considered and didn't do:</p>
<ul>
<li><p><strong>Rewriting the page copy:</strong> This is standard extraction advice. But I rejected it because the census showed a structural problem, not a prose problem. My authored sections already passed. Rewriting would have burned days and muddied the measurement.</p>
</li>
<li><p><strong>Plain</strong> <code>&lt;span&gt;</code><strong>/</strong><code>&lt;p&gt;</code> <strong>demotion without ARIA:</strong> Two fewer attributes per element. I rejected this because it deletes real navigation structure for screen reader users. The audit wouldn't have noticed the difference, but people would've.</p>
</li>
<li><p><strong>Stuffing FAQ schema on every page:</strong> F5 is worth 10 points and JSON-LD is cheap. I rejected this as the <em>first</em> move because it treats the symptom with metadata while leaving the false outline in place. Schema asserts what your page means but the DOM is what gets chunked. Fix the DOM first.</p>
</li>
<li><p><strong>Auditing every page on the sitemap:</strong> Completeness is seductive. I rejected this because extraction fixes multiply retrieval, and most pages have little retrieval to multiply. Three index pages covered the highest-impression surfaces and every card component in one pass.</p>
</li>
<li><p><strong>Chasing a 100 score as a standing target:</strong> After watching my homepage drift from 100 to 80 through an unrelated redesign while staying comfortably in the EXTRACTABLE band, I set the CI gate at the 75 threshold, not at 100. Gating at perfection turns every content experiment into a CI failure and teaches your team to ignore the gate.</p>
</li>
</ul>
<h2 id="heading-faq">FAQ</h2>
<h3 id="heading-does-demoting-headings-hurt-my-regular-seo">Does demoting headings hurt my regular SEO?</h3>
<p>The headings that matter for search are the ones describing your document's own structure, and those stay untouched. What you're removing is markup that claimed <em>other documents'</em> titles as your outline.</p>
<p>My organic search impressions grew over the months following the fix. Nothing in Google's guidance requires card titles to be heading elements.</p>
<h3 id="heading-is-this-just-gaming-one-audit-script">Is this just gaming one audit script?</h3>
<p>The five checks encode how retrieval-augmented systems actually process pages: chunk by heading, embed chunks, and lift opening sentences of matching chunks. A false outline degrades that pipeline no matter whose script measures it. You're not optimizing for my auditor. Instead, you're fixing the DOM that every parser sees. The score is a proxy, which is exactly why Step 6 gates the band, not the number.</p>
<h3 id="heading-i-use-react-or-vue-not-svelte-does-anything-change">I use React or Vue, not Svelte. Does anything change?</h3>
<p>Nothing structural. The bug lives in JSX and SFC templates identically (<code>&lt;h3&gt;{title}&lt;/h3&gt;</code> inside a <code>Card.tsx</code>), the grep in Step 4 finds it, and <code>role="heading"</code> with <code>aria-level</code> works in every framework because it's plain HTML.</p>
<h3 id="heading-what-about-the-headings-inside-my-actual-articles">What about the headings inside my actual articles?</h3>
<p>Leave them as real <code>&lt;h2&gt;</code>/<code>&lt;h3&gt;</code> elements. Article body headings are your document's structure and they're precisely what should be in the census. The demotion pattern applies only to components that surface <em>other</em> pages' titles: cards, teasers, related-post widgets, and navigation panels.</p>
<h3 id="heading-how-often-should-i-re-audit">How often should I re-audit?</h3>
<p>Continuously, which is what Step 6 buys you: the CI gate re-audits on every production deployment, so you never re-audit by hand again.</p>
<p>If you skip the gate, run the Step 2 script monthly and after any change to layout components, navigation, or templates. Content edits inside a page rarely move the score much. Component and template changes are what reshape the census, and those are exactly the changes nobody thinks to re-measure. My own 100 to 80 homepage drift came from a redesign, not from writing.</p>
<h3 id="heading-my-score-is-low-but-i-have-no-card-components-now-what">My score is low but I have no card components. Now what?</h3>
<p>Then your failure class is authored, not structural: statement headings (rephrase into questions users type), openers outside the 40 to 60 word band (densify), or a missing FAQ block (add one). The census from Step 2 tells you which. The fixes are writing work rather than component work.</p>
<h2 id="heading-what-you-accomplished">What You Accomplished</h2>
<p>You measured a property of your site most owners have never seen: the heading census your components actually emit, and the extractability score it produces.</p>
<p>You traced low scores to the specific components responsible, applied a demotion pattern that satisfies extraction parsers and screen readers simultaneously, and wired a CI gate so the score can never silently regress again.</p>
<p>The wider context, from the first two guides in this series: <a href="https://www.freecodecamp.org/news/how-to-measure-your-ai-citation-rate-across-chatgpt-perplexity-and-claude">measuring your AI citation rate across engines</a> tells you whether you're being cited, and <a href="https://www.freecodecamp.org/news/a-developers-guide-to-webmcp">shipping an agent-facing surface with WebMCP</a> prepares your site for agents that act rather than read.</p>
<p>This tutorial closes the loop in the middle: making the content you already have liftable. Retrieval determines whether engines see you, attribution determines whether they name you, and extraction, the stage you just audited, determines whether there's anything clean enough to quote.</p>
<p>Run the census on your top three pages this week. If your components are voting in your outline, you now know how to take the vote back.</p>
<p>Thanks for reading!</p>
<p>I'm Chudi Nnorukam, and I keep the longer version of this method, plus the tool that automates the mechanical half of it, at <a href="http://chudi.dev">chudi.dev</a>. Check out this page: <a href="https://chudi.dev/tools/aeo-audit">https://chudi.dev/tools/aeo-audit</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Reusable Date-Time Picker in React with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-reusable-date-time-picker-in-react-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a60f42cb1ecbbb606535a7b</guid>
                
                    <category>
                        <![CDATA[ shadcn ui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:47:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7dcddc1c-2a9d-4af7-8f02-ffb76bea2c7b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps both in sync, and usually a range mode and a translated version somewhere down the line, too.</p>
<p>This guide walks through ready-made picker patterns you can drop into a React project today: a combined date and time picker, a date range picker, and a time picker.</p>
<p>Every one of these is available as a <a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Picker</strong></a> component you can install with a single CLI command instead of building from scratch.</p>
<p>These components are built on both Radix and Base UI primitives, and the versions below use Base UI. They also support copy-prompt functionality, so you can paste them straight into v0, Lovable, or Bolt if that's part of your workflow.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-install-a-shadcn-date-time-picker">How to Install a Shadcn Date Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-and-time-picker">How to Build a Date and Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-range-picker">How to Build a Date Range Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-time-picker">How to Build a Time Picker</a></p>
</li>
<li><p><a href="#heading-live-preview-of-the-components">Live Preview of the components</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should know:</p>
<ul>
<li><p>The basics of React, including <code>useState</code> and props</p>
</li>
<li><p>How to install components with the shadcn/ui CLI</p>
</li>
<li><p>Basic Tailwind CSS class names</p>
</li>
</ul>
<p>You also need a React project with shadcn/ui already set up. If you haven't done that yet, run the shadcn/ui CLI setup command in your project before continuing.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<ul>
<li><p>A <code>DateTimePicker</code> component that combines a calendar and time slots into one value.</p>
</li>
<li><p>A <code>TimePicker</code> variant that reuses the same time-slot logic without a calendar.</p>
</li>
<li><p>A <code>DateRangePicker</code> that lets a user pick a start and end date.</p>
</li>
</ul>
<h2 id="heading-how-to-install-a-shadcn-date-time-picker"><strong>How to Install a Shadcn Date Time Picker</strong></h2>
<p>All the components below install through the same CLI pattern. Pick the package manager you use:</p>
<p><strong>pnpm</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>npm</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>yarn</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>bun</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p>Every other component below installs the same way: just swap the package name at the end of the command. If you haven't set up the CLI in your project yet, this <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>getting-started guide</strong></a> covers that first and shows how to integrate these components when you're working through an MCP-connected editor.</p>
<h2 id="heading-how-to-build-a-date-and-time-picker"><strong>How to Build a Date and Time Picker</strong></h2>
<p>This is the combined picker: a calendar popover for the date, plus start and end time fields, wrapped around a booking confirmation flow.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-01.tsx
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon, Clock, ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

const DateAndTimePickerDemo = () =&gt; {
  const [open, setOpen] = useState(false);
  const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
  const [bookingStatus, setBookingStatus] = useState&lt;
    "idle" | "loading" | "success"
  &gt;("idle");

  const handleBooking = () =&gt; {
    setBookingStatus("loading");
    setTimeout(() =&gt; setBookingStatus("success"), 1500);
  };

  return (
    &lt;&gt;
      &lt;div className="grid gap-6"&gt;
        &lt;div className="grid gap-2"&gt;
          &lt;Label htmlFor="date" className="text-sm font-semibold"&gt;
            Select Date
          &lt;/Label&gt;
          &lt;Popover open={open} onOpenChange={setOpen}&gt;
            &lt;PopoverTrigger
              onPointerDown={() =&gt; setBookingStatus("idle")}
              render={
                &lt;Button
                  variant="outline"
                  id="date"
                  className={cn(
                    "w-full justify-start text-left font-normal h-10 transition-all hover:bg-muted/50 cursor-pointer",
                    !date &amp;&amp; "text-muted-foreground",
                  )}
                &gt;
                  &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                  {date ? format(date, "PPP") : &lt;span&gt;Select a date&lt;/span&gt;}
                  &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
                &lt;/Button&gt;
              }
            /&gt;
            &lt;PopoverContent
              className="w-auto p-0 border-muted-foreground/10 shadow-2xl"
              align="start"
            &gt;
              &lt;Calendar
                mode="single"
                selected={date}
                onSelect={(d) =&gt; {
                  setDate(d);
                  setOpen(false);
                }}
                className="rounded-md border-none"
              /&gt;
            &lt;/PopoverContent&gt;
          &lt;/Popover&gt;
        &lt;/div&gt;

        &lt;div className="grid grid-cols-2 gap-4"&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-from"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; Start Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-from"
              defaultValue="09:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-to"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; End Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-to"
              defaultValue="10:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;Button
          className="w-full h-11 font-semibold transition-all group overflow-hidden relative cursor-pointer"
          onClick={handleBooking}
          disabled={!date || bookingStatus !== "idle"}
        &gt;
          {bookingStatus === "idle" &amp;&amp; (
            &lt;span className="flex items-center gap-2"&gt;Confirm Meet&lt;/span&gt;
          )}
          {bookingStatus === "loading" &amp;&amp; (
            &lt;div className="flex items-center gap-2"&gt;
              &lt;div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /&gt;
              Processing...
            &lt;/div&gt;
          )}
          {bookingStatus === "success" &amp;&amp; (
            &lt;span className="flex items-center gap-2 animate-in zoom-in-50 duration-300"&gt;
              &lt;Check className="h-4 w-4" /&gt;
              Meet Scheduled!
            &lt;/span&gt;
          )}
        &lt;/Button&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
};

export default DateAndTimePickerDemo;
</code></pre>
<h3 id="heading-how-this-component-works">How This Component Works</h3>
<p>This component combines three pieces of state into a simple booking flow:</p>
<ul>
<li><p>The selected date</p>
</li>
<li><p>Start and end times</p>
</li>
<li><p>The booking status (idle, loading, success)</p>
</li>
</ul>
<p>The date is stored using React state:</p>
<pre><code class="language-javascript">const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
</code></pre>
<p>When a user selects a day from the calendar, the <code>onSelect</code> callback updates the state and closes the popover:</p>
<pre><code class="language-javascript">onSelect={(d) =&gt; {
  setDate(d);
  setOpen(false);
}}
</code></pre>
<p>The calendar itself lives inside a <code>Popover</code>, which keeps the UI compact. Clicking the trigger button opens the calendar panel:</p>
<pre><code class="language-plaintext">&lt;Popover open={open} onOpenChange={setOpen}&gt;
</code></pre>
<p>The displayed date uses <code>date-fns</code> formatting:</p>
<pre><code class="language-plaintext">format(date, "PPP")
</code></pre>
<p>This converts a JavaScript <code>Date</code> object into a readable format such as:</p>
<pre><code class="language-plaintext">July 22, 2026
</code></pre>
<p>The time fields use native HTML inputs:</p>
<pre><code class="language-plaintext">&lt;Input type="time" /&gt;
</code></pre>
<p>Native time inputs provide built-in browser support, mobile pickers, keyboard accessibility, and locale-aware formatting without additional libraries.</p>
<p>The booking button demonstrates how UI state can change during an async action:</p>
<pre><code class="language-plaintext">"idle" → "loading" → "success"
</code></pre>
<p>In a real application, <code>handleBooking()</code> would typically call an API endpoint instead of using <code>setTimeout</code>.</p>
<p>This pattern works well for:</p>
<ul>
<li><p>Meeting schedulers</p>
</li>
<li><p>Appointment systems</p>
</li>
<li><p>Interview booking tools</p>
</li>
<li><p>Event registration forms</p>
</li>
<li><p>SaaS scheduling workflows</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-date-range-picker"><strong>How to Build a Date Range Picker</strong></h2>
<p>For anything involving a stay, a rental, or a multi-day booking, you need a range instead of a single date. This component pairs a two-month calendar view with a formatted range label and a computed night count.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-02.tsx
</code></pre>
<p>Install this Shadcn Date Range Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-02
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import * as React from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { addDays, format } from "date-fns";
import { DateRange } from "react-day-picker";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";

const DateRangePickerDemo = () =&gt; {
  const [date, setDate] = React.useState&lt;DateRange | undefined&gt;({
    from: new Date(),
    to: addDays(new Date(), 7),
  });

  return (
    &lt;div className="grid gap-3 max-w-sm mx-auto"&gt;
      &lt;Label htmlFor="date-range" className="text-sm font-medium px-1"&gt;
        Select Travel Dates
      &lt;/Label&gt;
      &lt;div className={cn("grid gap-2")}&gt;
        &lt;Popover&gt;
          &lt;PopoverTrigger
            render={
              &lt;Button
                id="date-range"
                variant={"outline"}
                className={cn(
                  "w-full justify-start text-left font-normal h-11 transition-all hover:bg-muted/50 focus:ring-2 focus:ring-primary/20 cursor-pointer",
                  !date &amp;&amp; "text-muted-foreground",
                )}
              &gt;
                &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                {date?.from ? (
                  date.to ? (
                    &lt;&gt;
                      {format(date.from, "LLL dd, y")} -{" "}
                      {format(date.to, "LLL dd, y")}
                    &lt;/&gt;
                  ) : (
                    format(date.from, "LLL dd, y")
                  )
                ) : (
                  &lt;span&gt;Pick a range&lt;/span&gt;
                )}
                &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
              &lt;/Button&gt;
            }
          /&gt;
          &lt;PopoverContent
            className="w-auto p-0 border-muted/20 shadow-xl"
            align="start"
          &gt;
            &lt;Calendar
              mode="range"
              defaultMonth={date?.from}
              selected={date}
              onSelect={setDate}
              numberOfMonths={2}
              className="p-3"
            /&gt;
          &lt;/PopoverContent&gt;
        &lt;/Popover&gt;
      &lt;/div&gt;
      &lt;p className="text-xs text-muted-foreground px-1"&gt;
        {date?.from &amp;&amp; date?.to
          ? `Stay duration: ${Math.round((date.to.getTime() - date.from.getTime()) / (1000 * 60 * 60 * 24))} nights`
          : "Please select a valid date range."}
      &lt;/p&gt;
    &lt;/div&gt;
  );
};

export default DateRangePickerDemo;
</code></pre>
<h3 id="heading-how-the-date-range-picker-works">How the Date Range Picker Works</h3>
<p>This version switches the Calendar component from single-date mode to range mode:</p>
<pre><code class="language-javascript">mode="range"
</code></pre>
<p>Instead of storing one <code>Date</code>, the component stores a <code>DateRange</code> object:</p>
<pre><code class="language-javascript">{
  from: Date,
  to: Date
}
</code></pre>
<p>This makes it easy to work with booking systems, hotel stays, travel forms, and rental applications.</p>
<p>The component displays two calendar months:</p>
<pre><code class="language-javascript">numberOfMonths={2}
</code></pre>
<p>Showing two months reduces navigation and improves the selection experience for longer stays.</p>
<p>The stay duration is calculated directly from the selected dates:</p>
<pre><code class="language-javascript">(date.to.getTime() - date.from.getTime())
</code></pre>
<p>This avoids additional libraries and keeps the logic simple.</p>
<p>Compared to the first example:</p>
<ul>
<li><p>Uses <code>DateRange</code> instead of a single <code>Date</code></p>
</li>
<li><p>Uses <code>mode="range"</code></p>
</li>
<li><p>Displays two months</p>
</li>
<li><p>Adds derived data like total nights</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-time-picker"><strong>How to Build a Time Picker</strong></h2>
<p>Not every form needs a calendar. This one wraps the native in an InputGroup with a clock icon that triggers the browser's own time picker UI on click.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-03.tsx
</code></pre>
<p>Install this Shadcn Time Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-03
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useRef } from "react";
import { Label } from "@/components/ui/label";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Clock8Icon } from "lucide-react";

const TimePickerWithIconDemo = () =&gt; {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null);

  const handleShowPicker = () =&gt; {
    if (inputRef.current &amp;&amp; "showPicker" in inputRef.current) {
      try {
        inputRef.current.showPicker();
      } catch (error) {
        console.error("Failed to open native picker:", error);
      }
    }
  };

  return (
    &lt;div className="flex w-full max-w-xs flex-col gap-2"&gt;
      &lt;Label htmlFor="time-picker"&gt;Select Slot&lt;/Label&gt;
      &lt;InputGroup&gt;
        &lt;InputGroupAddon
          align="inline-start"
          className="cursor-pointer hover:text-foreground transition-colors"
          onClick={handleShowPicker}
          title="Open time picker"
        &gt;
          &lt;Clock8Icon className="size-4" /&gt;
        &lt;/InputGroupAddon&gt;
        &lt;InputGroupInput
          ref={inputRef}
          type="time"
          id="time-picker"
          step="1"
          defaultValue="08:30:00"
          className="appearance-none [&amp;::-webkit-calendar-picker-indicator]:hidden [&amp;::-webkit-calendar-picker-indicator]:appearance-none"
        /&gt;
      &lt;/InputGroup&gt;
    &lt;/div&gt;
  );
};
export default TimePickerWithIconDemo;
</code></pre>
<h3 id="heading-how-the-time-picker-works">How the Time Picker Works</h3>
<p>This component uses the browser's native time picker instead of building a custom dropdown.</p>
<p>The input is referenced with <code>useRef</code>:</p>
<pre><code class="language-javascript">const inputRef = useRef&lt;HTMLInputElement&gt;(null);
</code></pre>
<p>This allows the icon button to access the input element directly.</p>
<p>When users click the clock icon, the browser's picker opens programmatically:</p>
<pre><code class="language-javascript">inputRef.current.showPicker();
</code></pre>
<p>The <code>showPicker()</code> method is a modern browser API that opens the same interface users would see if they clicked the input manually.</p>
<p>The component wraps the input inside an <code>InputGroup</code>:</p>
<pre><code class="language-plaintext">&lt;InputGroup&gt;
</code></pre>
<p>This creates a cleaner layout where the icon behaves as part of the field rather than a separate button.</p>
<p>The browser's default picker icon is hidden:</p>
<pre><code class="language-plaintext">[&amp;::-webkit-calendar-picker-indicator]:hidden
</code></pre>
<p>This prevents duplicate icons and gives full control over the UI.</p>
<p>The benefit of this approach is that it keeps:</p>
<ul>
<li><p>Native accessibility</p>
</li>
<li><p>Mobile keyboard support</p>
</li>
<li><p>Locale-aware formatting</p>
</li>
<li><p>Better browser compatibility</p>
</li>
</ul>
<p>Instead of rebuilding time selection from scratch, the component improves the native experience with custom styling.</p>
<h2 id="heading-live-preview-of-the-components"><strong>Live Preview of the Components</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f815455d-9f2d-4ad1-ac9c-155c37aae094.gif" alt="f815455d-9f2d-4ad1-ac9c-155c37aae094" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-key-concepts-recap"><strong>Key Concepts Recap</strong></h2>
<ul>
<li><p><strong>Pick the pattern that matches the data you're collecting</strong>: A single date and time for bookings, a range for stays, a bare time field for slots.</p>
</li>
<li><p><strong>Every component installs the same way</strong>: One CLI command per component: <code>pnpm</code>, <code>npm</code>, <code>yarn</code>, and <code>bun</code>. All are supported. Adding a new picker to a project takes one line, not a manual build.</p>
</li>
<li><p><strong>The date and time picker separates date state from time state</strong>, then combines them at the confirmation step. This keeps the calendar and the time inputs from getting out of sync.</p>
</li>
<li><p><strong>The range picker leans on the Calendar's built-in</strong> <code>mode="range"</code>: The two-month view and the night count come from the selection state, not custom range math.</p>
</li>
<li><p><strong>The time picker wraps the native</strong> <code>&lt;input type="time"&gt;</code> <strong>instead of replacing it</strong>: This keeps native accessibility and mobile keyboard behavior intact.</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You now have working picker patterns for React: a date and time picker for bookings, a date range picker for stays, and a plain time picker for slots.</p>
<p>Each one installs with a single CLI command, so you can pick whichever one matches the form you're building and have it running in minutes.</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://ui.shadcn.com/docs"><strong>shadcn/ui documentation</strong></a>: For the official Popover, Calendar, and Button component APIs</p>
</li>
<li><p><a href="https://date-fns.org/docs/Getting-Started"><strong>date-fns documentation</strong></a>: For the date helper functions and locale files used in this tutorial</p>
</li>
<li><p><a href="https://daypicker.dev/"><strong>react-day-picker documentation</strong></a>: For the range and multi-date selection modes that power the shadcn/ui Calendar</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions"><strong>MDN: ARIA live regions</strong></a>: For more on how <code>role="alert"</code> works</p>
</li>
<li><p><a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Time Picker components</strong></a>: If you want more prebuilt date and time picker variants to compare against.</p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Full Shadcn components library</strong></a>: For other shadcn/ui-compatible components</p>
</li>
<li><p><a href="https://wrappixel.com/blog/shadcn-date-picker"><strong>Guide to free Shadcn date and time pickers</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF Signature Tool Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ PDF documents are commonly used for agreements, forms, approvals, invoices, reports, applications, and other documents that may need a signature or additional text before they are shared. A traditiona ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-signature-tool-javascript/</link>
                <guid isPermaLink="false">6a5e89518186f4c5817d466b</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Hashnode ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:47:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/175ab1f9-2917-4588-9e67-50607f6fa5a1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>PDF documents are commonly used for agreements, forms, approvals, invoices, reports, applications, and other documents that may need a signature or additional text before they are shared.</p>
<p>A traditional workflow often involves printing the document, signing it by hand, scanning it again, and sending the new file. For a simple electronic signature, that process adds unnecessary steps.</p>
<p>In this tutorial, you'll build a browser-based PDF Signature Tool using JavaScript. Users will be able to upload a PDF, preview and navigate its pages, and add content directly to the document.</p>
<p>The application will support two main element types: <strong>Signature</strong> and <strong>Text/Stamp</strong>.</p>
<p>For signatures, users can draw directly in the browser, type their name and choose a signature style, or upload an existing signature image. For text-based elements, they can enter custom text or use preset stamps such as <strong>APPROVED</strong>, <strong>CONFIDENTIAL</strong>, <strong>DRAFT</strong>, and <strong>PAID</strong>.</p>
<p>After creating an element, users can position it on the PDF preview and adjust properties such as scale, rotation, opacity, font size, and color. The element can then be applied to the current page, every page, or a specific set of pages.</p>
<p>Once processing is complete, the application generates a new PDF for review. Users can preview the result, rename the output file, check its page count and file size, and download it directly from the browser.</p>
<p>The project uses PDF.js for document rendering and PDF-lib for modifying and generating the final PDF.</p>
<p>By the end of this tutorial, you'll understand how to build an interactive PDF editing workflow that combines canvas-based input, image embedding, text placement, coordinate conversion, page selection, and client-side file generation.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-this-pdf-signature-tool-can-do">What This PDF Signature Tool Can Do</a></p>
</li>
<li><p><a href="#heading-electronic-signatures-vs-digital-signatures">Electronic Signatures vs Digital Signatures</a></p>
</li>
<li><p><a href="#heading-how-the-browser-based-workflow-works">How the Browser-Based Workflow Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-what-libraries-are-we-using">What Libraries Are We Using?</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-the-pdf">Uploading and Previewing the PDF</a></p>
</li>
<li><p><a href="#heading-choosing-an-element-to-add">Choosing an Element to Add</a></p>
</li>
<li><p><a href="#heading-creating-a-signature">Creating a Signature</a></p>
</li>
<li><p><a href="#heading-drawing-a-signature">Drawing a Signature</a></p>
</li>
<li><p><a href="#heading-typing-a-signature">Typing a Signature</a></p>
</li>
<li><p><a href="#heading-uploading-a-signature-image">Uploading a Signature Image</a></p>
</li>
<li><p><a href="#heading-adding-text-and-preset-stamps">Adding Text and Preset Stamps</a></p>
</li>
<li><p><a href="#heading-positioning-and-styling-the-element">Positioning and Styling the Element</a></p>
</li>
<li><p><a href="#heading-applying-the-element-to-selected-pages">Applying the Element to Selected Pages</a></p>
</li>
<li><p><a href="#heading-applying-and-finalizing-the-pdf">Applying and Finalizing the PDF</a></p>
</li>
<li><p><a href="#heading-generating-the-signed-pdf">Generating the Signed PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-final-pdf">Previewing the Final PDF</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading-the-final-pdf">Renaming and Downloading the Final PDF</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-signature-tool-works">Demo: How the PDF Signature Tool Works</a></p>
</li>
<li><p><a href="#heading-handling-signature-transparency">Handling Signature Transparency</a></p>
</li>
<li><p><a href="#heading-important-notes-and-common-mistakes">Important Notes and Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-signature-tool-can-do">What This PDF Signature Tool Can Do</h2>
<p>The application provides a single editing workflow for adding signatures, text, and common document stamps to PDF pages.</p>
<p>When <strong>Signature</strong> is selected, users can create the signature in three different ways.</p>
<ol>
<li><p>The <strong>Draw</strong> option provides a canvas where the user can write a signature using a mouse, trackpad, stylus, or touch input.</p>
</li>
<li><p>The <strong>Type</strong> option converts entered text into a signature-style element. Users can type their name, adjust the size, and choose from the available signature styles.</p>
</li>
<li><p>The <strong>Upload</strong> option accepts an existing signature image. This is useful for someone who already has a transparent PNG or another supported image of their handwritten signature.</p>
</li>
</ol>
<p>The second element type is <strong>Text/Stamp</strong>. Users can enter custom text such as:</p>
<pre><code class="language-text">Signed on: 08-09-2025
</code></pre>
<p>They can also quickly choose a predefined stamp:</p>
<pre><code class="language-text">APPROVED
CONFIDENTIAL
DRAFT
PAID
</code></pre>
<p>After an element has been created, the application provides controls for its placement and appearance. Users can move it to the required location and adjust its scale, rotation, opacity, and position.</p>
<p>Text and stamp elements can additionally use configurable font sizes and colors.</p>
<p>The page controls determine where the selected element will be applied. A signature may belong only on the final page of a contract, while a <code>CONFIDENTIAL</code> stamp may need to appear on every page.</p>
<p>The application therefore supports:</p>
<pre><code class="language-text">Current page only
All pages
Specific pages
</code></pre>
<p>The goal is to provide one consistent workflow for several common PDF editing tasks without requiring separate tools for each element type.</p>
<h2 id="heading-electronic-signatures-vs-digital-signatures">Electronic Signatures vs Digital Signatures</h2>
<p>Before building the application, it's important to distinguish between an <strong>electronic signature</strong> and a <strong>digital signature</strong>.</p>
<p>The tool in this tutorial creates an electronic signature workflow.</p>
<p>A drawn signature, typed signature, or uploaded signature image is placed visually onto the PDF page. This is similar to signing a document by hand and inserting a visible representation of that signature into the file.</p>
<p>For example, a user might draw a signature on a canvas:</p>
<pre><code class="language-javascript">const signatureImage =
    signatureCanvas.toDataURL("image/png");
</code></pre>
<p>The generated image can then be embedded into the PDF.</p>
<p>A digital signature is technically different.</p>
<p>Certificate-based digital signatures use cryptographic methods to help verify document integrity and the identity associated with a signing certificate. They may involve digital certificates, private keys, signature validation, and trust chains.</p>
<p>Simply placing a handwritten signature image on a PDF doesn't create that type of cryptographic verification.</p>
<p>This distinction matters because the terms are sometimes used interchangeably in everyday conversation even though the underlying technologies are different.</p>
<p>The project we're building focuses on <strong>visual electronic signatures and document elements</strong>. It doesn't create certificate-based cryptographic digital signatures.</p>
<p>Keeping that distinction clear makes it easier to understand exactly what the application does and what would require a more advanced signing system.</p>
<h2 id="heading-how-the-browser-based-workflow-works">How the Browser-Based Workflow Works</h2>
<p>The process begins when a user selects a PDF file.</p>
<p>PDF.js loads the document and renders the current page into a browser canvas. Previous and next buttons allow the user to navigate through the PDF before choosing where to place an element.</p>
<p>The user then selects one of two element types:</p>
<pre><code class="language-text">Signature
Text/Stamp
</code></pre>
<p>If <strong>Signature</strong> is selected, the application provides three creation methods:</p>
<pre><code class="language-text">Draw
Type
Upload
</code></pre>
<p>The selected signature is converted into an element that can be displayed over the PDF preview.</p>
<p>If <strong>Text/Stamp</strong> is selected, the application instead creates a text element using either custom content or one of the predefined stamp values.</p>
<p>The complete workflow looks like this:</p>
<pre><code class="language-text">Upload PDF
    ↓
Render and Navigate Pages
    ↓
Choose Signature or Text/Stamp
    ↓
Create the Element
    ↓
Position and Style It
    ↓
Choose Target Pages
    ↓
Apply &amp; Finalize
    ↓
Generate the New PDF
    ↓
Preview the Result
    ↓
Rename and Download
</code></pre>
<p>During editing, the element displayed over the PDF preview is only a browser-side representation. Its position must later be translated into coordinates that match the actual PDF page.</p>
<p>For example, the application may store an element like this:</p>
<pre><code class="language-javascript">const element = {
    type: "signature",
    x: 622,
    y: 496,
    scale: 1.14,
    rotation: 0,
    opacity: 1
};
</code></pre>
<p>When the user clicks <strong>Apply &amp; Finalize</strong>, those values are used to calculate the final placement inside the PDF.</p>
<p>This separation between the interactive preview and the final PDF generation is the foundation of the project. It allows users to visually prepare the document first and create the modified PDF only after the placement is ready.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>To keep the project easy to understand, we'll use three main files:</p>
<pre><code class="language-text">pdf-signature-tool/
│
├── index.html
├── style.css
└── script.js
</code></pre>
<p>The HTML file contains the upload interface, PDF preview, editing controls, final preview, and download section.</p>
<p>The CSS file handles the layout and visual states.</p>
<p>The JavaScript file manages PDF loading, page rendering, signature creation, text and stamp elements, positioning, final PDF generation, and downloading.</p>
<p>Start with the basic HTML structure:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;

    &lt;meta charset="UTF-8"&gt;

    &lt;meta
        name="viewport"
        content="width=device-width, initial-scale=1.0"&gt;

    &lt;title&gt;PDF Signature Tool&lt;/title&gt;

    &lt;link
        rel="stylesheet"
        href="style.css"&gt;

&lt;/head&gt;

&lt;body&gt;

    &lt;main class="pdf-signature-tool"&gt;

        &lt;section id="uploadSection"&gt;

            &lt;h1&gt;PDF Signature Tool&lt;/h1&gt;

            &lt;p&gt;
                Upload your PDF to add your
                electronic signature.
            &lt;/p&gt;

            &lt;div id="dropZone"&gt;

                &lt;p&gt;Drag &amp; Drop PDF Here&lt;/p&gt;

                &lt;p&gt;Or click to browse file&lt;/p&gt;

                &lt;button id="selectPdfButton"&gt;
                    Select PDF
                &lt;/button&gt;

                &lt;input
                    type="file"
                    id="pdfInput"
                    accept="application/pdf"
                    hidden&gt;

            &lt;/div&gt;

        &lt;/section&gt;

        &lt;section
            id="editorSection"
            hidden&gt;

            &lt;div class="pdf-preview"&gt;

                &lt;div id="previewContainer"&gt;

                    &lt;canvas id="pdfCanvas"&gt;&lt;/canvas&gt;

                    &lt;div id="elementLayer"&gt;&lt;/div&gt;

                &lt;/div&gt;

                &lt;div class="page-navigation"&gt;

                    &lt;button id="previousPage"&gt;
                        &amp;lt;
                    &lt;/button&gt;

                    &lt;span id="pageInfo"&gt;
                        Page 1 of 1
                    &lt;/span&gt;

                    &lt;button id="nextPage"&gt;
                        &amp;gt;
                    &lt;/button&gt;

                &lt;/div&gt;

            &lt;/div&gt;

            &lt;aside id="editorControls"&gt;

                &lt;!-- Signature and text controls
                     will be added here --&gt;

            &lt;/aside&gt;

        &lt;/section&gt;

        &lt;section
            id="resultSection"
            hidden&gt;

            &lt;!-- Final preview and download
                 controls will be added here --&gt;

        &lt;/section&gt;

    &lt;/main&gt;

    &lt;script src="script.js"&gt;&lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>The <code>previewContainer</code> is especially important.</p>
<p>It contains two layers:</p>
<pre><code class="language-text">PDF Canvas
    +
Interactive Element Layer
</code></pre>
<p>The PDF page is rendered onto the canvas, while signatures, text, and stamps are displayed in a separate overlay.</p>
<p>This allows users to move and style an element without modifying the original PDF every time they make a small adjustment.</p>
<p>The overlay should match the dimensions and position of the PDF canvas.</p>
<pre><code class="language-css">#previewContainer {
    position: relative;
    display: inline-block;
}

#pdfCanvas {
    display: block;
}

#elementLayer {
    position: absolute;
    inset: 0;
    pointer-events: none;
}
</code></pre>
<p>Individual signature and text elements can later enable their own pointer interactions.</p>
<pre><code class="language-css">.pdf-element {
    position: absolute;
    cursor: move;
    pointer-events: auto;
    transform-origin: center;
}
</code></pre>
<p>This layered structure becomes the foundation of the interactive editor.</p>
<h2 id="heading-what-libraries-are-we-using">What Libraries Are We Using?</h2>
<p>This project uses two JavaScript libraries for different parts of the PDF workflow.</p>
<h3 id="heading-pdfjs-for-rendering-and-previewing">PDF.js for Rendering and Previewing</h3>
<p>PDF.js is responsible for reading the uploaded document and rendering its pages inside the browser.</p>
<p>A page can be loaded like this:</p>
<pre><code class="language-javascript">const page =
    await pdfDocument.getPage(
        currentPage
    );
</code></pre>
<p>The page is then rendered to a canvas:</p>
<pre><code class="language-javascript">const viewport =
    page.getViewport({
        scale: 1.5
    });

const context =
    pdfCanvas.getContext("2d");

pdfCanvas.width =
    viewport.width;

pdfCanvas.height =
    viewport.height;

await page.render({

    canvasContext: context,

    viewport

}).promise;
</code></pre>
<p>PDF.js handles the visual preview.</p>
<h3 id="heading-pdf-lib-for-modifying-the-pdf">PDF-lib for Modifying the PDF</h3>
<p>PDF-lib is used later when the user clicks <strong>Apply &amp; Finalize</strong>.</p>
<p>It allows us to load the original PDF bytes and add content to its pages.</p>
<p>For example:</p>
<pre><code class="language-javascript">const pdfDoc =
    await PDFLib.PDFDocument.load(
        originalPdfBytes
    );
</code></pre>
<p>An uploaded PNG signature can then be embedded:</p>
<pre><code class="language-javascript">const signatureImage =
    await pdfDoc.embedPng(
        signatureBytes
    );
</code></pre>
<p>Text can also be drawn directly onto a PDF page:</p>
<pre><code class="language-javascript">page.drawText(
    "APPROVED",
    {
        x: 100,
        y: 100,
        size: 18
    }
);
</code></pre>
<p>The two libraries therefore have separate responsibilities:</p>
<pre><code class="language-text">PDF.js
→ Load and visually render PDF pages

PDF-lib
→ Modify pages and generate the final PDF
</code></pre>
<p>Separating these responsibilities keeps the editor easier to manage.</p>
<p>Include both libraries in the project before <code>script.js</code>.</p>
<pre><code class="language-html">&lt;script
    src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"&gt;
&lt;/script&gt;

&lt;script
    src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;
&lt;/script&gt;

&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker as well:</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc =
    "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
</code></pre>
<p>For a production project, pin and test the exact library versions you use rather than automatically loading an unspecified latest release.</p>
<h2 id="heading-uploading-and-previewing-the-pdf">Uploading and Previewing the PDF</h2>
<p>The first interactive step is accepting the user's PDF.</p>
<p>Get references to the required elements:</p>
<pre><code class="language-javascript">const pdfInput =
    document.getElementById(
        "pdfInput"
    );

const selectPdfButton =
    document.getElementById(
        "selectPdfButton"
    );

const dropZone =
    document.getElementById(
        "dropZone"
    );

const uploadSection =
    document.getElementById(
        "uploadSection"
    );

const editorSection =
    document.getElementById(
        "editorSection"
    );

const pdfCanvas =
    document.getElementById(
        "pdfCanvas"
    );
</code></pre>
<p>We also need a few variables to store the current document state.</p>
<pre><code class="language-javascript">let pdfDocument = null;

let originalPdfBytes = null;

let currentPage = 1;

let totalPages = 0;
</code></pre>
<p>Clicking the custom button opens the hidden file input.</p>
<pre><code class="language-javascript">selectPdfButton.addEventListener(
    "click",
    () =&gt; {

        pdfInput.click();

    }
);
</code></pre>
<p>When a file is selected, pass it to the PDF loading function.</p>
<pre><code class="language-javascript">pdfInput.addEventListener(
    "change",
    event =&gt; {

        const file =
            event.target.files[0];

        if (file) {

            loadPdf(file);

        }

    }
);
</code></pre>
<p>Before processing the file, validate its type.</p>
<pre><code class="language-javascript">async function loadPdf(file) {

    if (
        file.type !==
        "application/pdf"
    ) {

        alert(
            "Please select a valid PDF file."
        );

        return;

    }

}
</code></pre>
<p>Read the file as an <code>ArrayBuffer</code>.</p>
<pre><code class="language-javascript">const arrayBuffer =
    await file.arrayBuffer();
</code></pre>
<p>Keep a copy of the original bytes because PDF.js and PDF-lib will use the document at different stages.</p>
<pre><code class="language-javascript">originalPdfBytes =
    new Uint8Array(
        arrayBuffer
    );
</code></pre>
<p>Now load the document with PDF.js.</p>
<pre><code class="language-javascript">pdfDocument =
    await pdfjsLib
        .getDocument({
            data:
                originalPdfBytes.slice()
        })
        .promise;
</code></pre>
<p>Store the number of pages.</p>
<pre><code class="language-javascript">totalPages =
    pdfDocument.numPages;

currentPage = 1;
</code></pre>
<p>Switch from the upload interface to the editor.</p>
<pre><code class="language-javascript">uploadSection.hidden = true;

editorSection.hidden = false;
</code></pre>
<p>Finally, render the first page.</p>
<pre><code class="language-javascript">await renderPage(currentPage);
</code></pre>
<p>The complete loading function becomes:</p>
<pre><code class="language-javascript">async function loadPdf(file) {

    if (
        file.type !==
        "application/pdf"
    ) {

        alert(
            "Please select a valid PDF file."
        );

        return;

    }

    const arrayBuffer =
        await file.arrayBuffer();

    originalPdfBytes =
        new Uint8Array(
            arrayBuffer
        );

    pdfDocument =
        await pdfjsLib
            .getDocument({
                data:
                    originalPdfBytes.slice()
            })
            .promise;

    totalPages =
        pdfDocument.numPages;

    currentPage = 1;

    uploadSection.hidden = true;

    editorSection.hidden = false;

    await renderPage(currentPage);

}
</code></pre>
<p>For drag-and-drop support, prevent the browser's default behavior.</p>
<pre><code class="language-javascript">dropZone.addEventListener(
    "dragover",
    event =&gt; {

        event.preventDefault();

        dropZone.classList.add(
            "drag-active"
        );

    }
);
</code></pre>
<p>Remove the active state when the file leaves the drop area.</p>
<pre><code class="language-javascript">dropZone.addEventListener(
    "dragleave",
    () =&gt; {

        dropZone.classList.remove(
            "drag-active"
        );

    }
);
</code></pre>
<p>Handle the dropped file:</p>
<pre><code class="language-javascript">dropZone.addEventListener(
    "drop",
    event =&gt; {

        event.preventDefault();

        dropZone.classList.remove(
            "drag-active"
        );

        const file =
            event.dataTransfer.files[0];

        if (file) {

            loadPdf(file);

        }

    }
);
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6605c5ed-31d3-4a2a-a80d-95a77040d890.png" alt="PDF Signature Tool upload area with drag-and-drop support and Select PDF button." style="display:block;margin:0 auto" width="639" height="652" loading="lazy">

<h2 id="heading-rendering-the-current-pdf-page">Rendering the Current PDF Page</h2>
<p>The <code>renderPage()</code> function loads one page from the PDF and displays it on the canvas.</p>
<pre><code class="language-javascript">async function renderPage(
    pageNumber
) {

    const page =
        await pdfDocument.getPage(
            pageNumber
        );

    const viewport =
        page.getViewport({
            scale: 1.5
        });

    const context =
        pdfCanvas.getContext("2d");

    pdfCanvas.width =
        viewport.width;

    pdfCanvas.height =
        viewport.height;

    await page.render({

        canvasContext: context,

        viewport

    }).promise;

    updatePageInfo();

}
</code></pre>
<p>Because the interactive element layer sits above the canvas, it must use the same dimensions.</p>
<pre><code class="language-javascript">const elementLayer =
    document.getElementById(
        "elementLayer"
    );

elementLayer.style.width =
    `${viewport.width}px`;

elementLayer.style.height =
    `${viewport.height}px`;
</code></pre>
<p>Add those lines inside <code>renderPage()</code> after setting the canvas dimensions.</p>
<p>The page information can then be updated:</p>
<pre><code class="language-javascript">function updatePageInfo() {

    pageInfo.textContent =
        `Page ${currentPage} of ${totalPages}`;

}
</code></pre>
<p>At this point, the uploaded PDF page is visible, but users still need a way to move through multi-page documents.</p>
<h2 id="heading-adding-pdf-page-navigation">Adding PDF Page Navigation</h2>
<p>Get the navigation controls:</p>
<pre><code class="language-javascript">const previousPage =
    document.getElementById(
        "previousPage"
    );

const nextPage =
    document.getElementById(
        "nextPage"
    );

const pageInfo =
    document.getElementById(
        "pageInfo"
    );
</code></pre>
<p>The previous button decreases the page number.</p>
<pre><code class="language-javascript">previousPage.addEventListener(
    "click",
    async () =&gt; {

        if (currentPage &lt;= 1) {
            return;
        }

        currentPage--;

        await renderPage(
            currentPage
        );

    }
);
</code></pre>
<p>The next button moves forward.</p>
<pre><code class="language-javascript">nextPage.addEventListener(
    "click",
    async () =&gt; {

        if (
            currentPage &gt;=
            totalPages
        ) {
            return;
        }

        currentPage++;

        await renderPage(
            currentPage
        );

    }
);
</code></pre>
<p>The boundary checks prevent navigation outside the document.</p>
<p>For a 12-page PDF, the interface may display:</p>
<pre><code class="language-text">Page 12 of 12
</code></pre>
<p>The previous button remains available, while the next action can be disabled because the user is already on the final page.</p>
<pre><code class="language-javascript">function updateNavigationState() {

    previousPage.disabled =
        currentPage === 1;

    nextPage.disabled =
        currentPage ===
        totalPages;

}
</code></pre>
<p>Call this function whenever a new page is rendered.</p>
<pre><code class="language-javascript">function updatePageInfo() {

    pageInfo.textContent =
        `Page ${currentPage} of ${totalPages}`;

    updateNavigationState();

}
</code></pre>
<p>The user can now upload a PDF, preview its pages, and navigate to the exact page where a signature, custom text, or document stamp needs to be placed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e1b4c1e5-f088-4789-af4c-7cb9bb08bdd6.png" alt="Uploaded PDF displayed in the PDF Signature Tool with previous and next page navigation controls." style="display:block;margin:0 auto" width="708" height="550" loading="lazy">

<h2 id="heading-choosing-an-element-to-add">Choosing an Element to Add</h2>
<p>Once the PDF is loaded and the correct page is visible, the user can choose what type of element to place on the document.</p>
<p>The editor provides two options:</p>
<pre><code class="language-text">Signature
Text/Stamp
</code></pre>
<p>Create the element selector:</p>
<pre><code class="language-html">&lt;div class="element-selector"&gt;

    &lt;h3&gt;1. Choose Element&lt;/h3&gt;

    &lt;label&gt;
        &lt;input
            type="radio"
            name="elementType"
            value="signature"
            checked&gt;
        Signature
    &lt;/label&gt;

    &lt;label&gt;
        &lt;input
            type="radio"
            name="elementType"
            value="text"&gt;
        Text/Stamp
    &lt;/label&gt;

&lt;/div&gt;
</code></pre>
<p>Get the controls in JavaScript:</p>
<pre><code class="language-javascript">const elementTypeInputs =
    document.querySelectorAll(
        'input[name="elementType"]'
    );

const signatureControls =
    document.getElementById(
        "signatureControls"
    );

const textControls =
    document.getElementById(
        "textControls"
    );
</code></pre>
<p>Listen for changes:</p>
<pre><code class="language-javascript">elementTypeInputs.forEach(
    input =&gt; {

        input.addEventListener(
            "change",
            event =&gt; {

                const type =
                    event.target.value;

                if (
                    type ===
                    "signature"
                ) {

                    signatureControls.hidden =
                        false;

                    textControls.hidden =
                        true;

                } else {

                    signatureControls.hidden =
                        true;

                    textControls.hidden =
                        false;

                }

            }
        );

    }
);
</code></pre>
<p>This keeps the interface focused. Signature-specific controls appear only when the user is creating a signature, while text and stamp controls appear when that element type is selected.</p>
<h2 id="heading-creating-a-signature">Creating a Signature</h2>
<p>The signature workflow supports three methods:</p>
<pre><code class="language-text">Draw
Type
Upload
</code></pre>
<p>Create the method selector:</p>
<pre><code class="language-html">&lt;div id="signatureControls"&gt;

    &lt;h3&gt;2. Create Signature&lt;/h3&gt;

    &lt;div class="signature-tabs"&gt;

        &lt;button
            data-method="draw"
            class="active"&gt;
            Draw
        &lt;/button&gt;

        &lt;button
            data-method="type"&gt;
            Type
        &lt;/button&gt;

        &lt;button
            data-method="upload"&gt;
            Upload
        &lt;/button&gt;

    &lt;/div&gt;

    &lt;div id="drawPanel"&gt;&lt;/div&gt;

    &lt;div
        id="typePanel"
        hidden&gt;
    &lt;/div&gt;

    &lt;div
        id="uploadPanel"
        hidden&gt;
    &lt;/div&gt;

&lt;/div&gt;
</code></pre>
<p>Track the currently selected method:</p>
<pre><code class="language-javascript">let signatureMethod =
    "draw";
</code></pre>
<p>Switch between the three panels:</p>
<pre><code class="language-javascript">const signatureTabs =
    document.querySelectorAll(
        ".signature-tabs button"
    );

signatureTabs.forEach(
    button =&gt; {

        button.addEventListener(
            "click",
            () =&gt; {

                signatureMethod =
                    button.dataset.method;

                showSignatureMethod(
                    signatureMethod
                );

            }
        );

    }
);
</code></pre>
<p>The panel switching function can hide the inactive methods:</p>
<pre><code class="language-javascript">function showSignatureMethod(
    method
) {

    drawPanel.hidden =
        method !== "draw";

    typePanel.hidden =
        method !== "type";

    uploadPanel.hidden =
        method !== "upload";

}
</code></pre>
<p>Each method creates the same type of final element (a signature) but the source of that signature is different.</p>
<h2 id="heading-drawing-a-signature">Drawing a Signature</h2>
<p>The <strong>Draw</strong> option allows users to create a handwritten signature directly in the browser.</p>
<p>Add a canvas to the Draw panel:</p>
<pre><code class="language-html">&lt;div id="drawPanel"&gt;

    &lt;canvas
        id="signatureCanvas"
        width="500"
        height="180"&gt;
    &lt;/canvas&gt;

    &lt;button id="clearSignature"&gt;
        Clear
    &lt;/button&gt;

&lt;/div&gt;
</code></pre>
<p>Get the drawing context:</p>
<pre><code class="language-javascript">const signatureCanvas =
    document.getElementById(
        "signatureCanvas"
    );

const signatureContext =
    signatureCanvas.getContext(
        "2d"
    );

let isDrawing = false;
</code></pre>
<p>Begin drawing when the pointer touches the canvas:</p>
<pre><code class="language-javascript">signatureCanvas.addEventListener(
    "pointerdown",
    event =&gt; {

        isDrawing = true;

        const rect =
            signatureCanvas
                .getBoundingClientRect();

        signatureContext.beginPath();

        signatureContext.moveTo(

            event.clientX -
                rect.left,

            event.clientY -
                rect.top

        );

    }
);
</code></pre>
<p>Continue the line while the pointer moves:</p>
<pre><code class="language-javascript">signatureCanvas.addEventListener(
    "pointermove",
    event =&gt; {

        if (!isDrawing) {
            return;
        }

        const rect =
            signatureCanvas
                .getBoundingClientRect();

        signatureContext.lineTo(

            event.clientX -
                rect.left,

            event.clientY -
                rect.top

        );

        signatureContext.stroke();

    }
);
</code></pre>
<p>Stop drawing when the pointer is released:</p>
<pre><code class="language-javascript">signatureCanvas.addEventListener(
    "pointerup",
    () =&gt; {

        isDrawing = false;

    }
);

signatureCanvas.addEventListener(
    "pointerleave",
    () =&gt; {

        isDrawing = false;

    }
);
</code></pre>
<p>Set a few drawing properties:</p>
<pre><code class="language-javascript">signatureContext.lineWidth = 2;

signatureContext.lineCap =
    "round";

signatureContext.lineJoin =
    "round";
</code></pre>
<p>For touch devices, prevent the browser from interpreting drawing gestures as page scrolling:</p>
<pre><code class="language-css">#signatureCanvas {
    touch-action: none;
    cursor: crosshair;
}
</code></pre>
<p>The Clear button resets the drawing canvas:</p>
<pre><code class="language-javascript">clearSignature.addEventListener(
    "click",
    () =&gt; {

        signatureContext.clearRect(

            0,
            0,

            signatureCanvas.width,
            signatureCanvas.height

        );

    }
);
</code></pre>
<p>Once the signature is ready, convert the canvas into a PNG data URL:</p>
<pre><code class="language-javascript">const drawnSignature =
    signatureCanvas.toDataURL(
        "image/png"
    );
</code></pre>
<p>Because the canvas can preserve transparency, the resulting signature can be placed over the PDF without adding an unwanted rectangular background.</p>
<p>The generated image can now be displayed inside the interactive element layer.</p>
<pre><code class="language-javascript">function useDrawnSignature() {

    const image =
        new Image();

    image.src =
        signatureCanvas.toDataURL(
            "image/png"
        );

    image.onload =
        () =&gt; {

            createSignatureElement(
                image.src
            );

        };

}
</code></pre>
<p>This gives the user a visual signature element that can later be positioned over the PDF page.</p>
<h2 id="heading-typing-a-signature">Typing a Signature</h2>
<p>Not every user has a touchscreen, stylus, or existing signature image.</p>
<p>The <strong>Type</strong> option allows users to enter their name and choose a signature-style appearance.</p>
<p>Add the input controls:</p>
<pre><code class="language-html">&lt;div id="typePanel" hidden&gt;

    &lt;input
        type="text"
        id="typedSignature"
        placeholder="Type your name"&gt;

    &lt;label for="signatureFontSize"&gt;
        Font Size
    &lt;/label&gt;

    &lt;input
        type="number"
        id="signatureFontSize"
        value="45"
        min="12"
        max="120"&gt;

    &lt;div id="signatureStyles"&gt;
    &lt;/div&gt;

&lt;/div&gt;
</code></pre>
<p>Listen for text changes:</p>
<pre><code class="language-javascript">typedSignature.addEventListener(
    "input",
    updateTypedSignatures
);
</code></pre>
<p>Create several style previews:</p>
<pre><code class="language-javascript">const signatureFonts = [

    "cursive",

    "'Brush Script MT', cursive",

    "'Segoe Script', cursive"

];
</code></pre>
<p>Render the available options:</p>
<pre><code class="language-javascript">function updateTypedSignatures() {

    const value =
        typedSignature.value.trim();

    signatureStyles.innerHTML = "";

    if (!value) {
        return;
    }

    signatureFonts.forEach(
        font =&gt; {

            const option =
                document.createElement(
                    "button"
                );

            option.textContent =
                value;

            option.style.fontFamily =
                font;

            option.style.fontSize =
                `${signatureFontSize.value}px`;

            option.addEventListener(
                "click",
                () =&gt; {

                    createTypedSignature(
                        value,
                        font
                    );

                }
            );

            signatureStyles.appendChild(
                option
            );

        }
    );

}
</code></pre>
<p>A typed signature can be converted to an image using another canvas.</p>
<pre><code class="language-javascript">function createTypedSignature(
    text,
    fontFamily
) {

    const canvas =
        document.createElement(
            "canvas"
        );

    const context =
        canvas.getContext("2d");

    const fontSize =
        Number(
            signatureFontSize.value
        );

    context.font =
        `${fontSize}px ${fontFamily}`;

    const width =
        context.measureText(
            text
        ).width;

    canvas.width =
        Math.ceil(width + 40);

    canvas.height =
        Math.ceil(fontSize * 2);

    context.font =
        `${fontSize}px ${fontFamily}`;

    context.textBaseline =
        "middle";

    context.fillText(
        text,
        20,
        canvas.height / 2
    );

    const imageUrl =
        canvas.toDataURL(
            "image/png"
        );

    createSignatureElement(
        imageUrl
    );

}
</code></pre>
<p>The typed signature is now treated like the drawn signature: it becomes an image element that can be positioned and later embedded into the PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/8ebb9405-53f4-480d-b71e-cfb746f6fd5a.png" alt="Typed signature option showing a name, font size control, and multiple signature-style previews." style="display:block;margin:0 auto" width="334" height="682" loading="lazy">

<h2 id="heading-uploading-a-signature-image">Uploading a Signature Image</h2>
<p>The third option allows users to upload an existing signature image.</p>
<p>Add a file input:</p>
<pre><code class="language-html">&lt;div id="uploadPanel" hidden&gt;

    &lt;input
        type="file"
        id="signatureUpload"
        accept="image/png,image/jpeg"&gt;

    &lt;p id="selectedSignatureFile"&gt;
        No file chosen
    &lt;/p&gt;

&lt;/div&gt;
</code></pre>
<p>Listen for file selection:</p>
<pre><code class="language-javascript">signatureUpload.addEventListener(
    "change",
    event =&gt; {

        const file =
            event.target.files[0];

        if (!file) {
            return;
        }

        loadSignatureImage(file);

    }
);
</code></pre>
<p>Validate the image:</p>
<pre><code class="language-javascript">function loadSignatureImage(
    file
) {

    const allowedTypes = [

        "image/png",

        "image/jpeg"

    ];

    if (
        !allowedTypes.includes(
            file.type
        )
    ) {

        alert(
            "Please upload a PNG or JPEG image."
        );

        return;

    }

}
</code></pre>
<p>Read the selected image:</p>
<pre><code class="language-javascript">const reader =
    new FileReader();

reader.onload =
    event =&gt; {

        createSignatureElement(
            event.target.result
        );

};

reader.readAsDataURL(file);
</code></pre>
<p>Display the selected filename:</p>
<pre><code class="language-javascript">selectedSignatureFile.textContent =
    `Selected: ${file.name}`;
</code></pre>
<p>The complete function becomes:</p>
<pre><code class="language-javascript">function loadSignatureImage(
    file
) {

    const allowedTypes = [

        "image/png",

        "image/jpeg"

    ];

    if (
        !allowedTypes.includes(
            file.type
        )
    ) {

        alert(
            "Please upload a PNG or JPEG image."
        );

        return;

    }

    selectedSignatureFile.textContent =
        `Selected: ${file.name}`;

    const reader =
        new FileReader();

    reader.onload =
        event =&gt; {

            createSignatureElement(
                event.target.result
            );

        };

    reader.readAsDataURL(file);

}
</code></pre>
<p>A transparent PNG usually works particularly well because only the signature strokes remain visible over the document.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/64561974-9b33-4391-a612-9966707433a1.png" alt="Upload signature option with a selected signature image displayed and positioned on the PDF preview." style="display:block;margin:0 auto" width="345" height="532" loading="lazy">

<h2 id="heading-creating-the-signature-preview-element">Creating the Signature Preview Element</h2>
<p>All three signature methods eventually call the same function:</p>
<pre><code class="language-javascript">createSignatureElement(imageUrl);
</code></pre>
<p>This means the rest of the editor doesn't need separate positioning logic for drawn, typed, and uploaded signatures.</p>
<p>Create the preview element:</p>
<pre><code class="language-javascript">let activeElement = null;

function createSignatureElement(
    imageUrl
) {

    elementLayer.innerHTML = "";

    const image =
        document.createElement(
            "img"
        );

    image.src =
        imageUrl;

    image.className =
        "pdf-element signature-element";

    image.style.left =
        "100px";

    image.style.top =
        "100px";

    image.style.width =
        "180px";

    elementLayer.appendChild(
        image
    );

    activeElement = {

        type: "signature",

        source: imageUrl,

        element: image,

        x: 100,

        y: 100,

        scale: 1,

        rotation: 0,

        opacity: 1

    };

}
</code></pre>
<p>The preview now represents the signature that will eventually be written into the PDF.</p>
<p>The same state object can later be updated when the user changes the signature's position, scale, rotation, or opacity.</p>
<h2 id="heading-adding-text-and-preset-stamps">Adding Text and Preset Stamps</h2>
<p>The second main element type is <strong>Text/Stamp</strong>.</p>
<p>This mode is useful when a document needs a short label, status, date, or other text rather than a handwritten signature.</p>
<p>Create the controls:</p>
<pre><code class="language-html">&lt;div id="textControls" hidden&gt;

    &lt;h3&gt;2. Add Text or Stamp&lt;/h3&gt;

    &lt;input
        type="text"
        id="customText"
        placeholder="Enter text"&gt;

    &lt;div class="stamp-options"&gt;

        &lt;button data-stamp="APPROVED"&gt;
            APPROVED
        &lt;/button&gt;

        &lt;button data-stamp="CONFIDENTIAL"&gt;
            CONFIDENTIAL
        &lt;/button&gt;

        &lt;button data-stamp="DRAFT"&gt;
            DRAFT
        &lt;/button&gt;

        &lt;button data-stamp="PAID"&gt;
            PAID
        &lt;/button&gt;

    &lt;/div&gt;

    &lt;label&gt;
        Size

        &lt;input
            type="number"
            id="textSize"
            value="16"
            min="8"
            max="120"&gt;
    &lt;/label&gt;

    &lt;label&gt;
        Color

        &lt;input
            type="color"
            id="textColor"
            value="#000000"&gt;
    &lt;/label&gt;

&lt;/div&gt;
</code></pre>
<p>Custom text can be displayed as the user types:</p>
<pre><code class="language-javascript">customText.addEventListener(
    "input",
    () =&gt; {

        createTextElement(
            customText.value
        );

    }
);
</code></pre>
<p>Preset stamps can update the same text input:</p>
<pre><code class="language-javascript">const stampButtons =
    document.querySelectorAll(
        "[data-stamp]"
    );

stampButtons.forEach(
    button =&gt; {

        button.addEventListener(
            "click",
            () =&gt; {

                const stamp =
                    button.dataset.stamp;

                customText.value =
                    stamp;

                createTextElement(
                    stamp
                );

            }
        );

    }
);
</code></pre>
<p>Create the text preview:</p>
<pre><code class="language-javascript">function createTextElement(
    text
) {

    if (!text.trim()) {

        elementLayer.innerHTML = "";

        activeElement = null;

        return;

    }

    elementLayer.innerHTML = "";

    const textElement =
        document.createElement(
            "div"
        );

    textElement.className =
        "pdf-element text-element";

    textElement.textContent =
        text;

    textElement.style.left =
        "100px";

    textElement.style.top =
        "100px";

    textElement.style.fontSize =
        `${textSize.value}px`;

    textElement.style.color =
        textColor.value;

    elementLayer.appendChild(
        textElement
    );

    activeElement = {

        type: "text",

        text,

        element:
            textElement,

        x: 100,

        y: 100,

        fontSize:
            Number(
                textSize.value
            ),

        color:
            textColor.value,

        rotation: 0,

        opacity: 1

    };

}
</code></pre>
<p>When the size changes, update the current element:</p>
<pre><code class="language-javascript">textSize.addEventListener(
    "input",
    () =&gt; {

        if (
            activeElement?.type !==
            "text"
        ) {
            return;
        }

        activeElement.fontSize =
            Number(
                textSize.value
            );

        activeElement
            .element
            .style
            .fontSize =
                `${textSize.value}px`;

    }
);
</code></pre>
<p>Do the same for the color:</p>
<pre><code class="language-javascript">textColor.addEventListener(
    "input",
    () =&gt; {

        if (
            activeElement?.type !==
            "text"
        ) {
            return;
        }

        activeElement.color =
            textColor.value;

        activeElement
            .element
            .style
            .color =
                textColor.value;

    }
);
</code></pre>
<p>The user can now enter custom content such as:</p>
<pre><code class="language-text">Signed on: 08-09-2025
</code></pre>
<p>or quickly select a predefined document stamp.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/18e1bb24-c802-4a40-b7dc-87772fbdbf0c.png" alt="Text and stamp editor showing custom text, Approved, Confidential, Draft, and Paid preset options with font size and color controls." style="display:block;margin:0 auto" width="1062" height="568" loading="lazy">

<p>At this point, the application can create content using all of the available input methods: a drawn signature, typed signature, uploaded signature image, custom text, or preset document stamp.</p>
<h2 id="heading-positioning-and-styling-the-element">Positioning and Styling the Element</h2>
<p>After creating a signature, text label, or preset stamp, the next step is positioning it correctly on the PDF page.</p>
<p>The preview element sits inside the <code>elementLayer</code> created earlier. Because this layer matches the PDF canvas dimensions, users can move the element visually before anything is written into the final PDF.</p>
<p>The editor also provides controls for:</p>
<ul>
<li><p>Scale</p>
</li>
<li><p>Rotation</p>
</li>
<li><p>Opacity</p>
</li>
<li><p>X position</p>
</li>
<li><p>Y position</p>
</li>
</ul>
<p>The exact controls can vary depending on the active element. For example, scale is particularly useful for signatures, while text size and color are handled by the Text/Stamp controls from the previous section.</p>
<p>Create the placement controls:</p>
<pre><code class="language-html">&lt;div id="placementControls"&gt;

    &lt;h3&gt;3. Placement &amp; Style&lt;/h3&gt;

    &lt;label&gt;
        Rotation (°)

        &lt;input
            type="number"
            id="rotationInput"
            value="0"&gt;
    &lt;/label&gt;

    &lt;label&gt;
        Opacity

        &lt;input
            type="range"
            id="opacityInput"
            min="0"
            max="100"
            value="100"&gt;
    &lt;/label&gt;

    &lt;label&gt;
        X Position

        &lt;input
            type="number"
            id="xPosition"
            value="100"&gt;
    &lt;/label&gt;

    &lt;label&gt;
        Y Position

        &lt;input
            type="number"
            id="yPosition"
            value="100"&gt;
    &lt;/label&gt;

&lt;/div&gt;
</code></pre>
<p>For signature elements, add a scale control:</p>
<pre><code class="language-html">&lt;label&gt;
    Scale (&lt;span id="scaleValue"&gt;100%&lt;/span&gt;)

    &lt;input
        type="range"
        id="scaleInput"
        min="25"
        max="250"
        value="100"&gt;
&lt;/label&gt;
</code></pre>
<p>Get the controls in JavaScript:</p>
<pre><code class="language-javascript">const scaleInput =
    document.getElementById(
        "scaleInput"
    );

const scaleValue =
    document.getElementById(
        "scaleValue"
    );

const rotationInput =
    document.getElementById(
        "rotationInput"
    );

const opacityInput =
    document.getElementById(
        "opacityInput"
    );

const xPosition =
    document.getElementById(
        "xPosition"
    );

const yPosition =
    document.getElementById(
        "yPosition"
    );
</code></pre>
<p>We'll use a single function to update the visual transformation.</p>
<pre><code class="language-javascript">function updateElementTransform() {

    if (!activeElement) {
        return;
    }

    activeElement.element.style.transform =
        `
            scale(${activeElement.scale})
            rotate(${activeElement.rotation}deg)
        `;

    activeElement.element.style.opacity =
        activeElement.opacity;

}
</code></pre>
<p>For text elements, initialize <code>scale</code> as <code>1</code> so the same transformation function can still be used.</p>
<pre><code class="language-javascript">activeElement = {

    type: "text",

    text,

    element: textElement,

    x: 100,

    y: 100,

    scale: 1,

    rotation: 0,

    opacity: 1

};
</code></pre>
<h3 id="heading-changing-the-element-scale">Changing the Element Scale</h3>
<p>When the user moves the scale slider, convert the percentage into a decimal value.</p>
<pre><code class="language-javascript">scaleInput.addEventListener(
    "input",
    () =&gt; {

        if (!activeElement) {
            return;
        }

        const percentage =
            Number(
                scaleInput.value
            );

        activeElement.scale =
            percentage / 100;

        scaleValue.textContent =
            `${percentage}%`;

        updateElementTransform();

    }
);
</code></pre>
<p>A value of <code>100%</code> represents the original preview size.</p>
<pre><code class="language-text">50%  → 0.5
100% → 1
114% → 1.14
200% → 2
</code></pre>
<p>This makes it easy to enlarge or reduce an uploaded, drawn, or typed signature without creating a new image.</p>
<h3 id="heading-rotating-the-element">Rotating the Element</h3>
<p>The rotation input stores the angle in degrees.</p>
<pre><code class="language-javascript">rotationInput.addEventListener(
    "input",
    () =&gt; {

        if (!activeElement) {
            return;
        }

        activeElement.rotation =
            Number(
                rotationInput.value
            );

        updateElementTransform();

    }
);
</code></pre>
<p>A rotation of <code>0</code> keeps the element horizontal, while positive or negative values rotate it around its center.</p>
<h3 id="heading-adjusting-opacity">Adjusting Opacity</h3>
<p>Opacity can be useful for stamps, watermarks, and other document labels.</p>
<p>Convert the percentage slider to a value between <code>0</code> and <code>1</code>.</p>
<pre><code class="language-javascript">opacityInput.addEventListener(
    "input",
    () =&gt; {

        if (!activeElement) {
            return;
        }

        activeElement.opacity =
            Number(
                opacityInput.value
            ) / 100;

        updateElementTransform();

    }
);
</code></pre>
<p>For example:</p>
<pre><code class="language-text">100% → 1
75%  → 0.75
50%  → 0.5
</code></pre>
<p>The same opacity value will later be used when generating the final PDF.</p>
<h2 id="heading-dragging-an-element-across-the-pdf-preview">Dragging an Element Across the PDF Preview</h2>
<p>Typing X and Y coordinates manually is useful for precise adjustments, but most users will prefer to drag the element directly to the required location.</p>
<p>Track the dragging state:</p>
<pre><code class="language-javascript">let isDragging = false;

let dragOffsetX = 0;

let dragOffsetY = 0;
</code></pre>
<p>When a signature or text element is created, attach the dragging behavior.</p>
<pre><code class="language-javascript">function enableDragging(
    element
) {

    element.addEventListener(
        "pointerdown",
        event =&gt; {

            isDragging = true;

            const elementRect =
                element
                    .getBoundingClientRect();

            dragOffsetX =
                event.clientX -
                elementRect.left;

            dragOffsetY =
                event.clientY -
                elementRect.top;

            element.setPointerCapture(
                event.pointerId
            );

        }
    );

}
</code></pre>
<p>Call this function when creating an element.</p>
<pre><code class="language-javascript">enableDragging(image);
</code></pre>
<p>or:</p>
<pre><code class="language-javascript">enableDragging(textElement);
</code></pre>
<p>Next, listen for pointer movement.</p>
<pre><code class="language-javascript">elementLayer.addEventListener(
    "pointermove",
    event =&gt; {

        if (
            !isDragging ||
            !activeElement
        ) {
            return;
        }

        const layerRect =
            elementLayer
                .getBoundingClientRect();

        const x =
            event.clientX -
            layerRect.left -
            dragOffsetX;

        const y =
            event.clientY -
            layerRect.top -
            dragOffsetY;

        moveActiveElement(
            x,
            y
        );

    }
);
</code></pre>
<p>Create a reusable movement function:</p>
<pre><code class="language-javascript">function moveActiveElement(
    x,
    y
) {

    if (!activeElement) {
        return;
    }

    activeElement.x = x;

    activeElement.y = y;

    activeElement.element.style.left =
        `${x}px`;

    activeElement.element.style.top =
        `${y}px`;

    xPosition.value =
        Math.round(x);

    yPosition.value =
        Math.round(y);

}
</code></pre>
<p>Stop dragging when the pointer is released.</p>
<pre><code class="language-javascript">elementLayer.addEventListener(
    "pointerup",
    () =&gt; {

        isDragging = false;

    }
);

elementLayer.addEventListener(
    "pointercancel",
    () =&gt; {

        isDragging = false;

    }
);
</code></pre>
<p>Now the signature or text element can be moved directly over the document.</p>
<p>For example, an uploaded signature may be positioned near the bottom-right corner of the final page.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/77fa703f-4636-4423-bb05-b97fc163d6de.png" alt="Uploaded signature image positioned on a PDF page with scale, rotation, opacity, X position, and Y position controls." style="display:block;margin:0 auto" width="1047" height="532" loading="lazy">

<h2 id="heading-updating-the-position-manually">Updating the Position Manually</h2>
<p>The X and Y fields provide another way to position the element.</p>
<p>Listen for changes to the X coordinate:</p>
<pre><code class="language-javascript">xPosition.addEventListener(
    "input",
    () =&gt; {

        if (!activeElement) {
            return;
        }

        const x =
            Number(
                xPosition.value
            );

        moveActiveElement(
            x,
            activeElement.y
        );

    }
);
</code></pre>
<p>Do the same for Y:</p>
<pre><code class="language-javascript">yPosition.addEventListener(
    "input",
    () =&gt; {

        if (!activeElement) {
            return;
        }

        const y =
            Number(
                yPosition.value
            );

        moveActiveElement(
            activeElement.x,
            y
        );

    }
);
</code></pre>
<p>Dragging and manual coordinate entry remain synchronized. Moving the element updates the fields, while changing the fields moves the preview element.</p>
<h2 id="heading-keeping-the-element-inside-the-page">Keeping the Element Inside the Page</h2>
<p>Without boundaries, users could accidentally drag an element completely outside the PDF preview.</p>
<p>We can limit the position before saving it.</p>
<pre><code class="language-javascript">function clampPosition(
    x,
    y
) {

    const element =
        activeElement.element;

    const maxX =
        elementLayer.clientWidth -
        element.offsetWidth;

    const maxY =
        elementLayer.clientHeight -
        element.offsetHeight;

    return {

        x:
            Math.max(
                0,
                Math.min(x, maxX)
            ),

        y:
            Math.max(
                0,
                Math.min(y, maxY)
            )

    };

}
</code></pre>
<p>Use it inside <code>moveActiveElement()</code>:</p>
<pre><code class="language-javascript">const position =
    clampPosition(
        x,
        y
    );

activeElement.x =
    position.x;

activeElement.y =
    position.y;
</code></pre>
<p>When scale or rotation is applied, the element's transformed visual bounds can extend beyond its original box. A production editor can use <code>getBoundingClientRect()</code> for more precise transformed-boundary calculations.</p>
<p>The basic clamp shown here is sufficient to demonstrate the positioning workflow.</p>
<h2 id="heading-applying-the-element-to-selected-pages">Applying the Element to Selected Pages</h2>
<p>After positioning the element, the user decides which PDF pages should receive it.</p>
<p>The interface provides three options:</p>
<pre><code class="language-text">Current page only
All pages
Specific pages
</code></pre>
<p>Create the controls:</p>
<pre><code class="language-html">&lt;div id="pageApplication"&gt;

    &lt;h3&gt;4. Apply to Pages&lt;/h3&gt;

    &lt;label&gt;
        &lt;input
            type="radio"
            name="applyMode"
            value="current"
            checked&gt;
        Current page only
    &lt;/label&gt;

    &lt;label&gt;
        &lt;input
            type="radio"
            name="applyMode"
            value="all"&gt;
        All pages
    &lt;/label&gt;

    &lt;label&gt;
        &lt;input
            type="radio"
            name="applyMode"
            value="specific"&gt;
        Specific pages
    &lt;/label&gt;

    &lt;input
        type="text"
        id="specificPages"
        placeholder="e.g., 1, 3-5, 10"&gt;

&lt;/div&gt;
</code></pre>
<p>Read the selected mode:</p>
<pre><code class="language-javascript">function getTargetPages() {

    const mode =
        document.querySelector(
            'input[name="applyMode"]:checked'
        ).value;

    if (
        mode ===
        "current"
    ) {

        return [
            currentPage
        ];

    }

    if (
        mode ===
        "all"
    ) {

        return Array.from(

            {
                length:
                    totalPages
            },

            (_, index) =&gt;
                index + 1

        );

    }

    return parsePageRange(
        specificPages.value
    );

}
</code></pre>
<p>Parse custom values such as:</p>
<pre><code class="language-text">1, 3-5, 10
</code></pre>
<p>with:</p>
<pre><code class="language-javascript">function parsePageRange(
    value
) {

    const pages =
        new Set();

    value
        .split(",")
        .forEach(part =&gt; {

            const item =
                part.trim();

            if (!item) {
                return;
            }

            if (
                item.includes("-")
            ) {

                const [
                    start,
                    end
                ] =
                    item
                        .split("-")
                        .map(Number);

                for (
                    let page = start;
                    page &lt;= end;
                    page++
                ) {

                    if (
                        page &gt;= 1 &amp;&amp;
                        page &lt;= totalPages
                    ) {

                        pages.add(page);

                    }

                }

            } else {

                const page =
                    Number(item);

                if (
                    page &gt;= 1 &amp;&amp;
                    page &lt;= totalPages
                ) {

                    pages.add(page);

                }

            }

        });

    return [...pages];

}
</code></pre>
<p>The result becomes:</p>
<pre><code class="language-javascript">[
    1,
    3,
    4,
    5,
    10
]
</code></pre>
<p>This allows a signature or stamp to be placed once and then applied to multiple target pages.</p>
<p>Just keep in mind that page dimensions may differ within the same PDF. Applying the same coordinates across pages works best when those pages use a consistent size and layout.</p>
<h2 id="heading-applying-and-finalizing-the-pdf">Applying and Finalizing the PDF</h2>
<p>Once the element is created, positioned, styled, and assigned to the correct pages, the user can click <strong>Apply &amp; Finalize</strong>.</p>
<p>Create the action buttons:</p>
<pre><code class="language-html">&lt;div class="editor-actions"&gt;

    &lt;button id="applyButton"&gt;
        Apply &amp; Finalize
    &lt;/button&gt;

    &lt;button id="startOverButton"&gt;
        Start Over
    &lt;/button&gt;

&lt;/div&gt;
</code></pre>
<p>Get the buttons:</p>
<pre><code class="language-javascript">const applyButton =
    document.getElementById(
        "applyButton"
    );

const startOverButton =
    document.getElementById(
        "startOverButton"
    );
</code></pre>
<p>Before generating the final PDF, make sure an element exists.</p>
<pre><code class="language-javascript">applyButton.addEventListener(
    "click",
    async () =&gt; {

        if (!activeElement) {

            alert(
                "Please add a signature, text, or stamp first."
            );

            return;

        }

        const targetPages =
            getTargetPages();

        if (
            targetPages.length === 0
        ) {

            alert(
                "Please select at least one valid page."
            );

            return;

        }

        await generateFinalPdf(
            targetPages
        );

    }
);
</code></pre>
<p>The <code>generateFinalPdf()</code> function will handle the actual PDF modification in the next section.</p>
<p>The <strong>Start Over</strong> button clears the current document and resets the application.</p>
<pre><code class="language-javascript">startOverButton.addEventListener(
    "click",
    resetTool
);
</code></pre>
<p>Create the reset function:</p>
<pre><code class="language-javascript">function resetTool() {

    pdfDocument = null;

    originalPdfBytes = null;

    currentPage = 1;

    totalPages = 0;

    activeElement = null;

    pdfInput.value = "";

    elementLayer.innerHTML = "";

    editorSection.hidden = true;

    resultSection.hidden = true;

    uploadSection.hidden = false;

}
</code></pre>
<p>This returns the application to its original upload state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e98a951a-609b-4e1e-b2a6-5cd2e31a6ac1.png" alt="Apply and Finalize button for adding the selected signature or text element to the PDF, with a Start Over option." style="display:block;margin:0 auto" width="362" height="86" loading="lazy">

<p>The interactive editing stage is now complete. Users can create a signature or text element, position it directly over the PDF, adjust its appearance, choose the target pages, and prepare the document for final processing.</p>
<h2 id="heading-generating-the-signed-pdf">Generating the Signed PDF</h2>
<p>The element shown over the browser preview hasn't yet been added to the actual PDF. When users click <strong>Apply &amp; Finalize</strong>, the application loads the original document with PDF-lib and writes the selected signature, text, or stamp onto the target pages.</p>
<p>Start by loading the original PDF bytes:</p>
<pre><code class="language-javascript">async function generateFinalPdf(
    targetPages
) {

    const pdfDoc =
        await PDFLib.PDFDocument.load(
            originalPdfBytes.slice()
        );

    const pages =
        pdfDoc.getPages();

}
</code></pre>
<p>Before placing the element, we need to convert its browser coordinates into PDF coordinates.</p>
<p>The preview canvas may be displayed at a different size from the actual PDF page. The coordinate systems also use different Y-axis origins.</p>
<p>For each target page, calculate the scale:</p>
<pre><code class="language-javascript">const {
    width: pdfWidth,
    height: pdfHeight
} = page.getSize();

const scaleX =
    pdfWidth /
    pdfCanvas.width;

const scaleY =
    pdfHeight /
    pdfCanvas.height;
</code></pre>
<p>Convert the preview position:</p>
<pre><code class="language-javascript">const pdfX =
    activeElement.x *
    scaleX;

const pdfY =
    pdfHeight -
    (
        activeElement.y +
        activeElement.element.offsetHeight
    ) * scaleY;
</code></pre>
<p>This conversion maps the element from the browser's top-left coordinate system to the PDF page's coordinate system.</p>
<h3 id="heading-embedding-a-signature">Embedding a Signature</h3>
<p>Drawn, typed, and uploaded signatures are all represented as images by the time they reach the final processing stage.</p>
<p>Convert the signature data URL into bytes:</p>
<pre><code class="language-javascript">async function dataUrlToBytes(
    dataUrl
) {

    const response =
        await fetch(dataUrl);

    return await response.arrayBuffer();

}
</code></pre>
<p>Embed the signature image:</p>
<pre><code class="language-javascript">const signatureBytes =
    await dataUrlToBytes(
        activeElement.source
    );

const signatureImage =
    await pdfDoc.embedPng(
        signatureBytes
    );
</code></pre>
<p>If uploaded JPEG signatures are supported, the application should preserve the original image format and use <code>embedJpg()</code> when appropriate.</p>
<p>Calculate the final dimensions:</p>
<pre><code class="language-javascript">const previewWidth =
    activeElement
        .element
        .offsetWidth *
    activeElement.scale;

const previewHeight =
    activeElement
        .element
        .offsetHeight *
    activeElement.scale;

const finalWidth =
    previewWidth *
    scaleX;

const finalHeight =
    previewHeight *
    scaleY;
</code></pre>
<p>Then draw the signature:</p>
<pre><code class="language-javascript">page.drawImage(
    signatureImage,
    {
        x: pdfX,

        y:
            pdfHeight -
            (
                activeElement.y *
                scaleY
            ) -
            finalHeight,

        width:
            finalWidth,

        height:
            finalHeight,

        rotate:
            PDFLib.degrees(
                activeElement.rotation
            ),

        opacity:
            activeElement.opacity
    }
);
</code></pre>
<p>The same processing logic works whether the signature was drawn, typed, or uploaded because all three methods produce an image element before finalization.</p>
<h3 id="heading-adding-text-or-a-stamp">Adding Text or a Stamp</h3>
<p>Text and preset stamps are written directly onto the PDF page.</p>
<p>First, convert the selected color from hexadecimal to RGB values.</p>
<pre><code class="language-javascript">function hexToRgb(
    hex
) {

    const value =
        hex.replace(
            "#",
            ""
        );

    return {

        r:
            parseInt(
                value.substring(0, 2),
                16
            ) / 255,

        g:
            parseInt(
                value.substring(2, 4),
                16
            ) / 255,

        b:
            parseInt(
                value.substring(4, 6),
                16
            ) / 255

    };

}
</code></pre>
<p>Apply the text:</p>
<pre><code class="language-javascript">const color =
    hexToRgb(
        activeElement.color
    );

page.drawText(
    activeElement.text,
    {
        x:
            activeElement.x *
            scaleX,

        y:
            pdfHeight -
            (
                activeElement.y *
                scaleY
            ) -
            activeElement.fontSize,

        size:
            activeElement.fontSize *
            scaleY,

        color:
            PDFLib.rgb(
                color.r,
                color.g,
                color.b
            ),

        rotate:
            PDFLib.degrees(
                activeElement.rotation
            ),

        opacity:
            activeElement.opacity
    }
);
</code></pre>
<p>After processing every target page, save the modified document:</p>
<pre><code class="language-javascript">const finalPdfBytes =
    await pdfDoc.save();

const finalPdfBlob =
    new Blob(
        [finalPdfBytes],
        {
            type:
                "application/pdf"
        }
    );

await showFinalPreview(
    finalPdfBlob
);
</code></pre>
<p>At this point, the selected signature, text, or stamp has been added to the generated PDF.</p>
<h2 id="heading-previewing-the-final-pdf">Previewing the Final PDF</h2>
<p>Before downloading the document, the application displays the completed PDF in a separate preview area.</p>
<p>This allows users to confirm that the element appears on the correct page and in the expected position.</p>
<p>Load the generated file with PDF.js:</p>
<pre><code class="language-javascript">let finalPdfDocument = null;

let finalPage = 1;

async function showFinalPreview(
    blob
) {

    const bytes =
        await blob.arrayBuffer();

    finalPdfDocument =
        await pdfjsLib
            .getDocument({
                data: bytes
            })
            .promise;

    finalPage = 1;

    editorSection.hidden =
        true;

    resultSection.hidden =
        false;

    await renderFinalPage(
        finalPage
    );

}
</code></pre>
<p>Render the current result page:</p>
<pre><code class="language-javascript">async function renderFinalPage(
    pageNumber
) {

    const page =
        await finalPdfDocument
            .getPage(
                pageNumber
            );

    const viewport =
        page.getViewport({
            scale: 1.4
        });

    finalCanvas.width =
        viewport.width;

    finalCanvas.height =
        viewport.height;

    await page.render({

        canvasContext:
            finalCanvas
                .getContext("2d"),

        viewport

    }).promise;

    finalPageInfo.textContent =
        `Page ${pageNumber} of ${finalPdfDocument.numPages}`;

}
</code></pre>
<p>Previous and next controls can use the same navigation pattern as the original PDF preview.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/8ca71960-392a-4c49-a138-f462786682a0.png" alt="Final PDF preview showing the applied signature or text element before downloading the document." style="display:block;margin:0 auto" width="704" height="547" loading="lazy">

<h2 id="heading-renaming-and-downloading-the-final-pdf">Renaming and Downloading the Final PDF</h2>
<p>After reviewing the processed document, users can rename the file before downloading it.</p>
<p>For example:</p>
<pre><code class="language-text">document_signed.pdf
</code></pre>
<p>Create the filename input:</p>
<pre><code class="language-html">&lt;input
    type="text"
    id="outputFilename"
    value="document_signed.pdf"&gt;
</code></pre>
<p>Make sure the filename has the correct extension:</p>
<pre><code class="language-javascript">function getOutputFilename() {

    let filename =
        outputFilename
            .value
            .trim();

    if (!filename) {

        filename =
            "document_signed.pdf";

    }

    if (
        !filename
            .toLowerCase()
            .endsWith(".pdf")
    ) {

        filename += ".pdf";

    }

    return filename;

}
</code></pre>
<p>The result section can also display the total page count and generated file size.</p>
<pre><code class="language-javascript">function formatFileSize(
    bytes
) {

    if (
        bytes &lt;
        1024 * 1024
    ) {

        return (
            bytes / 1024
        ).toFixed(2) + " KB";

    }

    return (
        bytes /
        1024 /
        1024
    ).toFixed(2) + " MB";

}
</code></pre>
<p>Update the file information:</p>
<pre><code class="language-javascript">filePageCount.textContent =
    `Total Pages: ${finalPdfDocument.numPages}`;

fileSize.textContent =
    `File Size: ${
        formatFileSize(
            finalPdfBlob.size
        )
    }`;
</code></pre>
<p>Download the file using a temporary object URL:</p>
<pre><code class="language-javascript">downloadButton.addEventListener(
    "click",
    () =&gt; {

        const url =
            URL.createObjectURL(
                finalPdfBlob
            );

        const link =
            document.createElement(
                "a"
            );

        link.href =
            url;

        link.download =
            getOutputFilename();

        link.click();

        URL.revokeObjectURL(
            url
        );

    }
);
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/96212b85-3504-4420-bfba-819c20ca5a6e.png" alt="Signed PDF download section with editable filename, total page count, file size, and Download button." style="display:block;margin:0 auto" width="355" height="280" loading="lazy">

<p>After downloading, the <strong>Start Over</strong> button resets the application so another PDF can be processed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/fd08536f-4b47-4b7b-930c-5943685ad58e.png" alt="Start Over button for clearing the current PDF signing session and uploading another document." style="display:block;margin:0 auto" width="150" height="54" loading="lazy">

<h2 id="heading-demo-how-the-pdf-signature-tool-works">Demo: How the PDF Signature Tool Works</h2>
<p>Let's walk through the complete workflow from upload to download.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>.</p>
<p>The browser reads the document and prepares it for local processing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/aa49b177-d750-4432-80db-51101eaa5650.png" alt="PDF Signature Tool upload area with drag-and-drop support and Select PDF button." style="display:block;margin:0 auto" width="639" height="652" loading="lazy">

<h3 id="heading-step-2-preview-and-navigate-the-document">Step 2: Preview and Navigate the Document</h3>
<p>After upload, the current page appears in the PDF preview.</p>
<p>Previous and next controls allow users to navigate through multi-page documents and find the page where an element needs to be added.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/61827efd-9872-4499-8d61-96f7a7c6a821.png" alt="Uploaded PDF preview with previous and next page navigation controls." style="display:block;margin:0 auto" width="708" height="550" loading="lazy">

<h3 id="heading-step-3-choose-what-to-add">Step 3: Choose What to Add</h3>
<p>The user chooses between <strong>Signature</strong> and <strong>Text/Stamp</strong>.</p>
<p>This determines which creation controls appear in the editor.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ba697ba0-2e86-4c35-9510-39503e706ac8.png" alt="PDF editing controls for choosing between a signature and a text or stamp element." style="display:block;margin:0 auto" width="342" height="755" loading="lazy">

<h3 id="heading-step-4-create-the-signature">Step 4: Create the Signature</h3>
<p>If Signature is selected, users can choose <strong>Draw</strong>, <strong>Type</strong>, or <strong>Upload</strong>.</p>
<p>Drawing works directly inside the signature canvas. The Type option creates a signature-style element from entered text, while Upload accepts an existing signature image.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/971b15a8-4322-45c3-83fb-cbb8eae3172d.png" alt="PDF signature creation controls with Draw, Type, and Upload options." style="display:block;margin:0 auto" width="543" height="350" loading="lazy">

<h3 id="heading-step-5-add-custom-text-or-a-preset-stamp">Step 5: Add Custom Text or a Preset Stamp</h3>
<p>Instead of a signature, users can select <strong>Text/Stamp</strong>.</p>
<p>They can enter custom content or choose a preset such as <strong>APPROVED</strong>, <strong>CONFIDENTIAL</strong>, <strong>DRAFT</strong>, or <strong>PAID</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/572bbac7-2b4d-49bd-8cae-399ca82c74b7.png" alt="Text and stamp controls with custom text and preset document stamp options." style="display:block;margin:0 auto" width="560" height="423" loading="lazy">

<h3 id="heading-step-6-position-and-style-the-element">Step 6: Position and Style the Element</h3>
<p>The created element appears over the PDF preview.</p>
<p>Users can drag it to the required position and adjust properties such as scale, rotation, opacity, X position, and Y position.</p>
<p>Text elements also support configurable font size and color.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d3424bda-ada1-4acd-8ce8-c55a631efc27.png" alt="Signature positioned over a PDF page with placement and styling controls." style="display:block;margin:0 auto" width="1047" height="532" loading="lazy">

<h3 id="heading-step-7-choose-the-target-pages">Step 7: Choose the Target Pages</h3>
<p>The element can be applied to the current page, every page, or a specific page selection.</p>
<p>For example:</p>
<pre><code class="language-text">1, 3-5, 10
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/10b0061f-57f2-4314-a7cb-41758bc00be6.png" alt="Choose the Target Pages or applied pages" style="display:block;margin:0 auto" width="329" height="252" loading="lazy">

<p>This is useful when the same stamp or document label needs to appear on several pages.</p>
<h3 id="heading-step-8-apply-and-finalize">Step 8: Apply and Finalize</h3>
<p>After checking the element and target pages, users click <strong>Apply &amp; Finalize</strong>.</p>
<p>The browser converts the preview position into PDF coordinates and generates the modified document.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/8ad34b20-777d-49c9-8900-4876e22ac8c1.png" alt="Apply and Finalize button for generating the PDF with the selected signature or text element." style="display:block;margin:0 auto" width="204" height="73" loading="lazy">

<h3 id="heading-step-9-preview-the-completed-pdf">Step 9: Preview the Completed PDF</h3>
<p>The generated document appears in a final preview.</p>
<p>Users can navigate through the pages and verify that the signature, text, or stamp appears correctly before downloading.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f7d699e6-bbb3-4692-b2bf-d2e1bb44bdc5.png" alt=" Completed PDF preview showing an applied signature before download." style="display:block;margin:0 auto" width="704" height="547" loading="lazy">

<h3 id="heading-step-10-rename-and-download">Step 10: Rename and Download</h3>
<p>The final section allows users to change the output filename and review the total number of pages and file size.</p>
<p>Clicking <strong>Download</strong> saves the generated PDF locally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/58186c5d-7f10-4860-b2c3-87f5862bd025.png" alt="Final PDF download section with filename editing, page count, file size, and Download button." style="display:block;margin:0 auto" width="355" height="280" loading="lazy">

<p>Afterward, <strong>Start Over</strong> clears the session and returns to the upload interface.</p>
<h2 id="heading-handling-signature-transparency">Handling Signature Transparency</h2>
<p>Uploaded signatures often look best when the background is transparent.</p>
<p>A transparent PNG contains only the visible signature strokes, allowing the original PDF content to remain visible around the signature.</p>
<p>A JPEG image, by comparison, usually includes a solid background. If the image was scanned from white paper, placing it on a colored PDF area may create a visible white rectangle.</p>
<p>For uploaded signatures, transparent PNG files are therefore usually the better option.</p>
<p>The same principle applies to drawn and typed signatures. When converting a canvas to PNG, avoid filling the canvas with a background color unless that background is intentionally required.</p>
<pre><code class="language-javascript">const signatureImage =
    signatureCanvas.toDataURL(
        "image/png"
    );
</code></pre>
<p>The transparent canvas can then be embedded directly into the PDF.</p>
<h2 id="heading-important-notes-and-common-mistakes">Important Notes and Common Mistakes</h2>
<p>One common mistake is assuming that the browser preview and the actual PDF use identical coordinates.</p>
<p>Always calculate the relationship between the canvas dimensions and the target PDF page before placing the final element.</p>
<pre><code class="language-javascript">const scaleX =
    pdfWidth /
    pdfCanvas.width;

const scaleY =
    pdfHeight /
    pdfCanvas.height;
</code></pre>
<p>Another issue occurs when the same element is applied to pages with different dimensions. A position that looks correct on an A4 page may not appear in the same visual location on a landscape or differently sized page.</p>
<p>Uploaded signature images should also be validated before processing.</p>
<pre><code class="language-javascript">const allowedTypes = [
    "image/png",
    "image/jpeg"
];

if (
    !allowedTypes.includes(
        file.type
    )
) {

    alert(
        "Please upload a PNG or JPEG image."
    );

    return;

}
</code></pre>
<p>Very large image files should be resized before embedding to avoid unnecessarily increasing the final PDF size.</p>
<p>Users should also review the completed document before downloading it. Rotation, scaling, or coordinate conversion errors are much easier to identify in the final preview than after the file has already been shared.</p>
<p>Finally, remember that this project adds a <strong>visual electronic signature</strong> to a PDF. It does not create a certificate-based cryptographic digital signature or provide automatic identity verification.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF Signature Tool using JavaScript.</p>
<p>You learned how to upload and preview PDF documents, navigate between pages, create signatures by drawing, typing, or uploading an image, add custom text and preset stamps, position elements directly over a PDF preview, adjust their appearance, choose target pages, and generate the completed document with PDF-lib.</p>
<p>You also learned how browser coordinates are converted into PDF coordinates and why signature transparency matters when embedding images into a document.</p>
<p>The final workflow allows users to preview the completed PDF, rename the output file, review its page count and size, and download it directly from the browser.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/sign-pdf/">PDF Signature Tool</a>.</p>
<p>The project can be extended further with multiple elements per page, reusable signature profiles, date fields, initials, custom fonts, signature removal before finalization, or certificate-based digital signing through a dedicated signing infrastructure.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
